From 6545d820954c2f4af28b88aabaf6fcdef17b6fe3 Mon Sep 17 00:00:00 2001 From: unfaiyted Date: Mon, 6 May 2024 21:23:06 -0500 Subject: [PATCH] chore: initial --- .idea/.gitignore | 8 + .idea/misc.xml | 9 + .idea/modules.xml | 8 + emby-scripts.iml | 9 + src/.cache | 1 + src/__init__.py | 0 src/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 155 bytes src/__pycache__/config.cpython-312.pyc | Bin 0 -> 979 bytes src/actions/.cache | 1 + src/actions/__init__.py | 0 src/actions/series_mix_playlist_generator.py | 57 ++ src/actions/sync_spotify_playlists.py | 113 +++ src/actions/sync_user_playlists.py | 64 ++ src/clients/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 163 bytes .../__pycache__/emby_client.cpython-312.pyc | Bin 0 -> 32121 bytes .../spotify_client.cpython-312.pyc | Bin 0 -> 3617 bytes src/clients/emby_client.py | 712 ++++++++++++++++++ src/clients/spotify_client.py | 75 ++ src/config.py | 35 + src/health.py | 74 ++ src/main.py | 31 + src/mesh-shows.py | 49 ++ src/services/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 164 bytes .../playlist_service.cpython-312.pyc | Bin 0 -> 2287 bytes src/services/playlist_service.py | 38 + src/tests/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 161 bytes .../emby_integration_test.cpython-312.pyc | Bin 0 -> 8866 bytes .../__pycache__/emby_test.cpython-312.pyc | Bin 0 -> 4408 bytes src/tests/emby_integration_test.py | 172 +++++ src/tests/emby_test.py | 55 ++ src/types/__init__.py | 0 src/utils/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 161 bytes src/utils/__pycache__/string.cpython-312.pyc | Bin 0 -> 1045 bytes src/utils/string.py | 16 + 38 files changed, 1527 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 emby-scripts.iml create mode 100644 src/.cache create mode 100644 src/__init__.py create mode 100644 src/__pycache__/__init__.cpython-312.pyc create mode 100644 src/__pycache__/config.cpython-312.pyc create mode 100644 src/actions/.cache create mode 100644 src/actions/__init__.py create mode 100644 src/actions/series_mix_playlist_generator.py create mode 100644 src/actions/sync_spotify_playlists.py create mode 100644 src/actions/sync_user_playlists.py create mode 100644 src/clients/__init__.py create mode 100644 src/clients/__pycache__/__init__.cpython-312.pyc create mode 100644 src/clients/__pycache__/emby_client.cpython-312.pyc create mode 100644 src/clients/__pycache__/spotify_client.cpython-312.pyc create mode 100644 src/clients/emby_client.py create mode 100644 src/clients/spotify_client.py create mode 100644 src/config.py create mode 100644 src/health.py create mode 100644 src/main.py create mode 100644 src/mesh-shows.py create mode 100644 src/services/__init__.py create mode 100644 src/services/__pycache__/__init__.cpython-312.pyc create mode 100644 src/services/__pycache__/playlist_service.cpython-312.pyc create mode 100644 src/services/playlist_service.py create mode 100644 src/tests/__init__.py create mode 100644 src/tests/__pycache__/__init__.cpython-312.pyc create mode 100644 src/tests/__pycache__/emby_integration_test.cpython-312.pyc create mode 100644 src/tests/__pycache__/emby_test.cpython-312.pyc create mode 100644 src/tests/emby_integration_test.py create mode 100644 src/tests/emby_test.py create mode 100644 src/types/__init__.py create mode 100644 src/utils/__init__.py create mode 100644 src/utils/__pycache__/__init__.cpython-312.pyc create mode 100644 src/utils/__pycache__/string.cpython-312.pyc create mode 100644 src/utils/string.py diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..bda831c --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..41de656 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/emby-scripts.iml b/emby-scripts.iml new file mode 100644 index 0000000..70d05d5 --- /dev/null +++ b/emby-scripts.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/.cache b/src/.cache new file mode 100644 index 0000000..cc9ae45 --- /dev/null +++ b/src/.cache @@ -0,0 +1 @@ +{"access_token": "BQCdJ8TcebCvCeo2VM75nwko_tshza2WqpKlhWK_EZufuP4NXOWEtaks1sUfnHlnJt258CbM2Hsx78XUNbu84_4u-uKEHgj51fPRJzAVg91O6MeSd-3ZpIUHbXLaNjnUVBS7ltPClDRHyCuaFaRW8BKlA_F-Nq-BrbPD4vgJnqboI3prb3BH4GTl1tfkInodNAfIog5oeReyHF8vy85KtrSugMZeSptlUInUd4Z-IZLqXg", "token_type": "Bearer", "expires_in": 3600, "scope": "playlist-read-private", "expires_at": 1715037521, "refresh_token": "AQAA4rKcyTFqaVctXWaxmE3d0LqLJu3ms91BNXrlJeYfy_1fYmCyQ3VyX6rO6K0O4iGyRVNWT8lcJB6MGRuC5rLXG85ZFB2XQtpcab6ECyKrIQNcMNU7AJj_eiZgfTwNtFk"} \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/__pycache__/__init__.cpython-312.pyc b/src/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f4bb91fd7b04b1feb312f9cf9e5ab960afb2626c GIT binary patch literal 155 zcmX@j%ge<81Pe_q(?IlN5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!vU0YH2`x@7DvohU z%u7}9&CJP3Es9CbPf1NmEKZF{%}uJ*Elw`VEGQ|CDK1KmiI30B%PfhH*DI*}#bJ}1 cpHiBWYFESxG>Q?3i$RQ!%#4hTMa)1J08-s0!~g&Q literal 0 HcmV?d00001 diff --git a/src/__pycache__/config.cpython-312.pyc b/src/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ef1ede1f2d26da8d0465c7958fbe84e7b31567a5 GIT binary patch literal 979 zcmZ8f%TC)s6m=ei@CffzP>`B-M-hpgI3`92i6*9`O+pj{6;`Ct*mnR+iJjU8$*fYT zmHG#KL)HK2rW>SgqFGhy51_KB#G>jr4x3)d$M>A$xp#bLzNb=?9E-ni~whs74oEAN1tF#zIwK&AI zF^Fpk7}Lf{l1xBihkXg-+9V{kDVWfvVNy%Mlr{s?+AO5BIhfJr{n_<7nkd`WnR!k# zBA?guGLZJmlG}nQ%QOupRM@^#z(l$iiKM5S>J*Yr|zH$fpr{e~CLIXI^a{9#!{GQMp!C z8b_#Fq3Qo5RVlZWBf9h`x0Fh?rIe4@e^zPgQB})_O~pyDL!~j-bLcEW*s^R7d!}u< zLEqtQ_L^1nRyiH9V^wJdEmPs#7;Eg+l@V?3sp`AKR)tOt-Ee~=Qc+H-WrfZRosa}| z!X76|ORXL@zHs-!t$Zkzx7)`qaolzVTO?CAJ00S*4I7A#UD6K5x9J*=+4J1C>ln-l zu3NU}JHf3ULXfz12A{Z}%iFi{^k8lGHoh`gJ-Cf850*Ci^}7U@=KBYqjUVycV6oV* zg|M{RuMd{i7%XJ^ZwCuc7^GkJ8-vBCA8JnKv3_Gc5;l7lC`0)<%N~+D;j!&HFBpxm W$DL(x7mGw9e-d+%)!!V0;evmJNE%20 literal 0 HcmV?d00001 diff --git a/src/actions/.cache b/src/actions/.cache new file mode 100644 index 0000000..a936a3f --- /dev/null +++ b/src/actions/.cache @@ -0,0 +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 diff --git a/src/actions/__init__.py b/src/actions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/actions/series_mix_playlist_generator.py b/src/actions/series_mix_playlist_generator.py new file mode 100644 index 0000000..db2a822 --- /dev/null +++ b/src/actions/series_mix_playlist_generator.py @@ -0,0 +1,57 @@ +from src.clients.emby_client import EmbyClient +from src.config import Config + +# 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"] + +# Create a new playlist +playlist_name = "Bob's Office Park" +playlist_type = "shows" +playlist = emby.create_playlist(playlist_name, playlist_type) + +# Get all the episodes for all of the TV shows in the array +all_episodes = {} +for show_name in tv_shows: + show = emby.search(show_name, "Series")[0] # Assuming the first search result is the desired show + seasons = emby.get_seasons(show["Id"]) + + all_episodes[show_name] = [] + for season in seasons: + episodes = emby.get_episodes(show["Id"], season["Id"]) + all_episodes[show_name].extend(episodes) + +# Sort the episodes of each show by season and episode number +for show_name in all_episodes: + all_episodes[show_name].sort(key=lambda x: (x["ParentIndexNumber"], x["IndexNumber"])) + +# Find the maximum number of episodes among all shows +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} + +# Initialize a set to keep track of added episode IDs +added_episode_ids = set() + +# Loop over the episodes and add them sequentially +for i in range(max_episodes): + for show_name in tv_shows: + if episode_counters[show_name] < len(all_episodes[show_name]): + episode = all_episodes[show_name][episode_counters[show_name]] + episode_id = episode["Id"] + + 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}") + added_episode_ids.add(episode_id) + else: + 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 diff --git a/src/actions/sync_spotify_playlists.py b/src/actions/sync_spotify_playlists.py new file mode 100644 index 0000000..17f6a4c --- /dev/null +++ b/src/actions/sync_spotify_playlists.py @@ -0,0 +1,113 @@ +import spotipy +from spotipy.oauth2 import SpotifyOAuth +import requests +import logging +from fuzzywuzzy import fuzz +import re +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 + +# 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) + +playlists = spot.get_playlists() + +# Iterate over each Spotify playlist +for playlist in playlists["items"]: + playlist_name = playlist["name"] + playlist_id = playlist["id"] + playlist_owner = playlist["owner"]["display_name"] + 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"]})" + ) + 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_playlist_tracks(playlist_id) + + logging.info(f"Processing {len(tracks)} tracks in Spotify playlist") + # Iterate over each track in the Spotify playlist + added_tracks = 0 + 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" + ) + logging.info("Added tracks: " + str(added_tracks)) + added_tracks = 0 diff --git a/src/actions/sync_user_playlists.py b/src/actions/sync_user_playlists.py new file mode 100644 index 0000000..50e29ea --- /dev/null +++ b/src/actions/sync_user_playlists.py @@ -0,0 +1,64 @@ +from src.clients.emby_client import EmbyClient +from src.services.playlist_service import PlaylistService +from src.config import Config + +# Create an instance of the Emby client for the source user +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_kids_usernames = ["Alyssa", "Azula", "Zuko"] + +# Get all the playlists for the source user +playlists = source_emby.get_playlists() +kids_playlists = source_emby.get_tagged_playlist('Kids') + +# Iterate over each playlist +for playlist in playlists: + # Iterate over the target usernames + for target_username in target_usernames: + # Check if the target username is in the playlist name + if target_username in playlist["Name"]: + try: + # Create an instance of the Emby client for the target user + target_emby = EmbyClient(Config.EMBY_URL, target_username, '') + # Create an instance of the PlaylistService + playlist_service = PlaylistService(source_emby, target_emby) + + # Check if the playlist already exists for the target user + existing_playlist = next((p for p in target_emby.get_playlists() if p["Name"] == playlist["Name"]), None) + + if existing_playlist: + # Delete the existing playlist + target_emby.delete_playlist(existing_playlist["Id"]) + print(f"Existing playlist '{playlist['Name']}' deleted for user '{target_username}'.") + + # Copy the playlist to the target user + new_playlist = playlist_service.copy_playlist_by_usernames(Config.EMBY_USERNAME, target_username, playlist["Id"]) + print(f"Playlist '{playlist['Name']}' copied successfully to user '{target_username}'. New playlist ID: {new_playlist['Id']}") + except ValueError as e: + print(f"Error copying playlist '{playlist['Name']}' to user '{target_username}': {str(e)}") + + # Check if the playlist has the tag 'Kids' +for playlist in kids_playlists: + # Iterate over the target kids usernames + for target_username in target_kids_usernames: + try: + # Create an instance of the Emby client for the target user + target_emby = EmbyClient(Config.EMBY_URL, target_username, '') + # Create an instance of the PlaylistService + playlist_service = PlaylistService(source_emby, target_emby) + + # Check if the playlist already exists for the target user + existing_playlist = next((p for p in target_emby.get_playlists() if p["Name"] == playlist["Name"]), None) + + if existing_playlist: + # Delete the existing playlist + target_emby.delete_playlist(existing_playlist["Id"]) + print(f"Existing playlist '{playlist['Name']}' deleted for user '{target_username}'.") + + # Copy the playlist to the target user + new_playlist = playlist_service.copy_playlist_by_usernames(Config.EMBY_USERNAME, target_username, playlist["Id"]) + print(f"Kids playlist '{playlist['Name']}' copied successfully to user '{target_username}'. New playlist ID: {new_playlist['Id']}") + except ValueError as e: + print(f"Error copying kids playlist '{playlist['Name']}' to user '{target_username}': {str(e)}") \ No newline at end of file diff --git a/src/clients/__init__.py b/src/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/clients/__pycache__/__init__.cpython-312.pyc b/src/clients/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f65a0555474b2fde925ebb674e5408f9e5b4e717 GIT binary patch literal 163 zcmX@j%ge<81hY&n(?IlN5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!a&oqc2`x@7DvohU z%u7}9&CJP3Es9CbPf1NmEKZF{%}uJ*Elw`VEGQ|CDK1KmNzTbk%>%OI<1_OzOXB18 j3Mzkb*yQG?l;)(`6|n-1WCY@35aS~=BO_xGGmr%U&Q>WC literal 0 HcmV?d00001 diff --git a/src/clients/__pycache__/emby_client.cpython-312.pyc b/src/clients/__pycache__/emby_client.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9a6e7e0431571beeddcbea529cd3014211e51d44 GIT binary patch literal 32121 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 literal 0 HcmV?d00001 diff --git a/src/clients/__pycache__/spotify_client.cpython-312.pyc b/src/clients/__pycache__/spotify_client.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ecae94bebfb12913497840ae10b8d56e0d0d35f7 GIT binary patch literal 3617 zcmb7HU2Gf25#Hn7@%Q*+v8F{*kxrr`l~N?dfgRVD8^iLC633ELD+X#p!GY$Tq*M5# z?j0eMJr+jc2c~rq=}8WXB7U^%JP1f0ggzBU9|{yGNHc(ls|*x~U;L&@0a~~(o!ui( zKXK}I0nN?M?#vEn=9^vp#qakb7!G`Db_=*~$fP~E7PE5&m{lYrnaQG*^<`2F%pF-r zj!m%)k+E4O$E7&Rb6Gy;OgS0kKodyj?;zRv$YEJaxk^mZ^_)zml8njC7RzP$3e$@P zU7ammJy+D{VeXsOv6`Q|uB%xsV`*BHX-#(C1ZEW}h&YmR$jlv-Vr2(VPG*7fG6&R2 z4o|scCs23Em2{i_)m<9FJPM29k(HI!Y+XP9-_hlc7gTs%-jOtfpkJqMHI%WECqJT`FSL0mNV~J+>Q1i89GZO#S!^Z;OvaYbV%KgT* z13OX14jD|G@JEyKCt#omnF?z#7tlvPMX194_Y@hN?1+L^I~f(;aDe71&~&~+(`oCt zb`!lod^s=--f*=`yW-YK-06UVaNBbB?ih#erZ{A<(@+HE6)(*Lkjb>3nHO836Vq5% zHC+>zRDE8&2wAWtMGe3p60DR&jUq$L5mk_m*v76YdJ*R}QJ+^}^_H3|=2`|sP0gv< zG*)#`&lIpC&K8P!c~=#a3OBiYI;SLgOe)0`GFc^^m#ot;NrB1Zw5}FR7K|r7_z)2V z+cnYh-4samNbOk&wzeh`J=hHFS1`l-=Mr(j^zAWXhW1RXWx+CUS=30(+wl}}2l2{J zgVXk+9B&2qJO$p!zQx0^U;8j1P6Xmh(? z*{hS|B6%7-svT);NWMRL$-aSDJ( z(t?i=LP`X864D36^jy4^Q5FHBdDE3G%*{b}FnNWTF9Qq=6M>KNlGO^@NVH}_Ye}ESiZ)-!%4Vnq)LltOrjWa-<`r4ml?7~> z)gl;0BU!aEO&JT=UJJX#+j8g2P)$}6psf8B2te8$@kBSf1~$5m*Sn6d&HS;ye&W4G z*X8PDvwQHtse1Q`>eXk_qZ`p<_2{v+haO)m1nW;&j&vn zy#LYK(60 z-Qy3ytRlcBd=HKVvg5!om^NA}4(J24r@(#duzCqT@jaI8KCx@gu(1Y~*agzh4ve<_ z_K6I#&tdMKux`pJlUH>mr@_sn+|rXA4uKIfxR_0svT##MI!i)q$0P zjgDl!Bl%#c(eZZG)jZUB?@0YnZ`Iih9A2LNRiPP)tR?yN3fraY4nhpN;<4i zMlwZ=;THFyXldkLg}4O6pDul}v=JPx2ZtNM z(VBboyFZDXLVprzIN$e^rV+jX%SnbhjxPbf*F7Kcpw+(0(eI|NwRD-Nkteb3S~Wl& zg1$-~v*h(5Nv2nlas|1VCA=U>KP#rQ)(Q@TE^W3Lo{ZuQDIW@ zl#qK-6M@`8+YS%!-Hr&nxZM@wquW1n@{#Qj!@s%XWcVYNGSN%A@eq74X@jPhKKqu) z+s|tF9Fd0LVLnnM{~nFBo@n%1pr=j}UGk=&p1g3Mv3|@unA|TU+SHvbmSLE`p};pN Y@D# Dict[str, Any]: + # filter_dict = filter_data.dict(exclude_none=True) + # + # # Remap keys as necessary + # remapped_keys = { + # 'search': 'SearchTerm', + # 'limit': 'Limit', + # 'listId': 'ParentId', + # 'offset': 'StartIndex', + # # ... add other remapped keys here ... + # } + # + # for key, new_key in remapped_keys.items(): + # if key in filter_dict: + # filter_dict[new_key] = filter_dict.pop(key) + # + # # Special handling for Filters + # filters_list = [] + # + # if 'isPlayed' in filter_dict: + # filters_list.append('IsUnplayed' if not filter_dict['isPlayed'] else 'IsPlayed') + # del filter_dict['isPlayed'] + # + # if 'isFavorite' in filter_dict and filter_dict['isFavorite']: + # filters_list.append('IsFavorite') + # del filter_dict['isFavorite'] + # + # if filters_list: + # filter_dict['Filters'] = ','.join(filters_list) + # + # # Capitalizing the first letter of each key in the dictionary + # filter_dict = {key.capitalize(): value for key, value in filter_dict.items()} + # + # return filter_dict + # + # def filter_search(self, filters: EmbyFilters) -> Dict[str, Any]: + # # Building the request URL + # url = self._build_url(f'Users/{self.user_id}/Items', self.build_query_parameters(filters)) + # + # response = self._get_request(url) + # return response.get('Items', []) + + def search_for_track(self, track_name, artist_name): + emby_search_results = None + url = self._build_url( + f"Items?SearchTerm={track_name}&Artists={artist_name}&Recursive=true&IncludeItemTypes=Audio&ExcludeItemTypes=Podcast&Limit=10") + try: + emby_search_response = self._get_request(url) + # print(emby_search_response) + # emby_search_response.raise_for_status() + emby_search_results = emby_search_response["Items"] + # print(emby_search_results) + except (requests.exceptions.RequestException, KeyError) as e: + logging.warning(f"Error searching for track in Emby: {track_name}") + logging.warning(f"Error message: {str(e)}") + + return emby_search_results + + def get_sessions(self): + url = self._build_url(f'Sessions') + response = self._get_request(url) + return response + + def play_item(self, session_id, item_id): + url = self._build_url(f'Sessions/{session_id}/Playing', {'ItemIds': item_id, 'PlayCommand': 'PlayNow'}) + response = self._post_request(url) + return response + + def send_message(self, session_id, message): + url = self._build_url(f'Sessions/{session_id}/Message', {'Text': message}) + response = self._post_request(url) + return response + + def get_movies(self, limit=50, is_played=None, is_favorite=None): + return self.get_media(limit, "Movie", is_played, is_favorite) + + def get_media(self, limit=50, item_types="Movie", genre=None, is_played=None, is_favorite=None, + external_id=None, name=None, year=None): + + params = {'Recursive': 'true', 'IncludeItemTypes': item_types, "Limit": limit} + + if is_played is not None: + params['IsPlayed'] = str(is_played) + if is_favorite is not None: + params['IsFavorite'] = str(is_favorite) + if genre is not None: + params['Genres'] = genre + if external_id is not None: + params['AnyProviderIdEquals'] = external_id + if name is not None: + params['Name'] = name + if year is not None: + params['ProductionYear'] = str(year) + + url = self._build_url(f'Users/{self.user_id}/Items', params) + response = self._get_request(url) + items = response.get('Items', []) + random.shuffle(items) + return items[:limit] + + def get_liked_movies(self, limit=50): + return self.get_movies(limit, is_favorite=True) + + def get_unwatched_movies(self, limit=50): + return self.get_movies(limit, is_played=False) + + def get_watched_series(self, limit=50): + return self.get_media(limit, "Series", is_played=True) + + def get_movies_by_genre(self, limit=50, genre="Action"): + return self.get_media(limit, "Movie", genre=genre) + + # @staticmethod + # def create_poster(path, text, root_path, icon_path=f'/resources/icons/tv.png'): + # width, height = 400, 600 + # start, end = (233, 0, 4), (88, 76, 76) + # angle = -160 + # font_path = f'{root_path}/resources/fonts/OpenSans-SemiBold.ttf' # path to your .ttf font file + # + # image_creator = PosterImageCreator(width, height, "cyan-teal", angle, font_path) + # img = image_creator.create_gradient().add_icon_with_text(icon_path, text) + # + # img.save(path, quality=95) + # return img + + #### + # MediaList based Methods + #### + + # def search_for_external_ids(self, media_item: MediaItem) -> Optional[dict]: + # item = None + # + # def search_id(external_id: str) -> Optional[dict]: + # try: + # search_results = self.get_media(external_id=external_id) + # if search_results and search_results[0]['Type'] != 'Trailer': + # return search_results[0] + # except Exception as e: + # print(f"Failed searching for {external_id} due to {e}") + # return None + # + # try: + # imdb_result = search_id(f"imdb.{media_item.providers.imdbId}") + # tvdb_result = search_id(f"tvdb.{media_item.providers.tvdbId}") + # except Exception as e: + # print(f"Failed searching for {media_item} due to {e}") + # return None + # + # if imdb_result: + # item = imdb_result + # elif tvdb_result: + # item = tvdb_result + # return item + # + # def create_collection_from_list(self, media_list: MediaList): + # collection = self.create_collection(media_list.name, media_list.type, media_list.sortName) + # # search emby for the items + # # add the first result to the collection + # + # print('-------------', media_list.items) + # for item in media_list.items: + # print('-------------', item) + # media_item = self.search_for_external_ids(item) + # if media_item: + # self.add_item_to_collection(collection['Id'], media_item['Id']) + # return collection + + def delete_collection_items(self, collection_id): + items = self.get_collection_items(collection_id) + for item in items: + self.delete_item_from_collection(collection_id, item['Id']) + return + + # def update_collection_from_list(self, media_list: MediaList): + # collection = self.get_list(media_list.sourceListId) + # if collection: + # self.delete_collection_items(collection['Id']) + # return self.create_collection_from_list(media_list) + # + # def create_playlist_from_list(self, media_list: MediaList): + # playlist = self.create_playlist(media_list.name, media_list.type, media_list.sortName) + # # search emby for the items + # # add the first result to the collection + # + # for item in media_list.items: + # print('-------------', item) + # emby_media_item = self.search_for_external_ids(item) + # if emby_media_item: + # self.add_item_to_playlist(playlist['Id'], emby_media_item['Id']) + # return playlist + # + # def update_playlist_from_list(self, media_list: MediaList): + # playlist = self.get_list(media_list.sourceListId) + # if playlist: + # 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 + + # 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 new file mode 100644 index 0000000..cb9e085 --- /dev/null +++ b/src/clients/spotify_client.py @@ -0,0 +1,75 @@ +import logging + +import spotipy +from fuzzywuzzy import fuzz +from spotipy import SpotifyOAuth +from src.utils.string import StringUtils + + +class SpotifyClient: + def __init__(self, client_id, client_secret, redirect_uri, scope): + # Authenticate with Spotify API + self.sp = spotipy.Spotify( + auth_manager=SpotifyOAuth( + client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri, + scope=scope, + ) + ) + + def match_artists(self, spotify_artists, emby_artists): + """ + Match Spotify artists with Emby artists using fuzzy string matching. + Returns the maximum artist similarity score found. + """ + max_artist_score = 0 + for spotify_artist in spotify_artists: + spotify_artist_name = StringUtils.clean_string(spotify_artist["name"]) + for emby_artist in emby_artists: + emby_artist_name = StringUtils.clean_string(emby_artist) + artist_score = fuzz.ratio(spotify_artist_name, emby_artist_name) + max_artist_score = max(max_artist_score, artist_score) + return max_artist_score + + def match_song(self, spotify_song, emby_song): + """ + Match a Spotify song with an Emby song using fuzzy string matching. + """ + try: + spotify_title = StringUtils.clean_string(spotify_song["name"]) + spotify_artists = spotify_song["artists"] + + emby_title = StringUtils.clean_string(emby_song["Name"]) + emby_artists = emby_song["Artists"] + + title_ratio = fuzz.ratio(spotify_title, emby_title) + artist_ratio = self.match_artists(spotify_artists, emby_artists) + + # Adjust the thresholds as needed + title_threshold = 90 + artist_threshold = 80 + combined_threshold = 170 # Sum of title and artist thresholds + + if title_ratio >= title_threshold and artist_ratio >= artist_threshold: + combined_score = title_ratio + artist_ratio + if combined_score >= combined_threshold: + return True + return False + except Exception as e: + logging.error(f"Error matching song: {str(e)}") + return False + + def get_playlist_tracks(self, playlist_id): + results = self.sp.playlist_tracks(playlist_id) + tracks = results["items"] + while results["next"]: + results = self.sp.next(results) + tracks.extend(results["items"]) + return tracks + + def get_playlists(self): + return self.sp.current_user_playlists() + + def get_spotify(self): + return self.sp \ No newline at end of file diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..f38c2dd --- /dev/null +++ b/src/config.py @@ -0,0 +1,35 @@ +# config.py + +class Config: + # 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" + + # 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" + + + +# 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" diff --git a/src/health.py b/src/health.py new file mode 100644 index 0000000..66b9270 --- /dev/null +++ b/src/health.py @@ -0,0 +1,74 @@ +# health.py +from config import Config +import spotipy +from spotipy.oauth2 import SpotifyOAuth +import requests + +def test_spotify_connection(): + try: + spotify = spotipy.Spotify(auth_manager=SpotifyOAuth( + client_id=Config.SPOTIFY_CLIENT_ID, + client_secret=Config.SPOTIFY_CLIENT_SECRET, + redirect_uri=Config.SPOTIFY_REDIRECT_URI, + scope=Config.SPOTIFY_SCOPE + )) + user = spotify.current_user() + print("Spotify connection successful.") + print(f"Logged in as: {user['display_name']} ({user['id']})") + except Exception as e: + print(f"Spotify connection failed: {str(e)}") + +def test_emby_connection(): + try: + headers = { + "X-Emby-Token": Config.EMBY_API_KEY, + "X-Emby-Client": Config.EMBY_CLIENT, + "X-Emby-Device-Name": Config.EMBY_DEVICE, + "X-Emby-Device-Id": Config.EMBY_DEVICE_ID, + "X-Emby-Client-Version": Config.EMBY_VERSION + } + response = requests.get(f"{Config.EMBY_URL}/emby/System/Info", headers=headers) + + if response.status_code == 200: + print("Emby connection successful.") + data = response.json() + print(f"Emby server version: {data['Version']}") + else: + print(f"Emby connection failed. Status code: {response.status_code}") + except Exception as e: + print(f"Emby connection failed: {str(e)}") + + +def get_emby_profiles(): + try: + headers = { + "X-Emby-Token": Config.EMBY_API_KEY, + "X-Emby-Client": Config.EMBY_CLIENT, + "X-Emby-Device-Name": Config.EMBY_DEVICE, + "X-Emby-Device-Id": Config.EMBY_DEVICE_ID, + "X-Emby-Client-Version": Config.EMBY_VERSION + } + response = requests.get(f"{Config.EMBY_URL}/emby/Users", headers=headers) + + if response.status_code == 200: + profiles_data = response.json() + print("Valid profiles:") + for profile in profiles_data: + print(f"- ID: {profile['Id']}, Name: {profile['Name']}") + else: + print(f"Failed to retrieve profiles. Status code: {response.status_code}") + except Exception as e: + print(f"Failed to retrieve profiles: {str(e)}") + + + +if __name__ == "__main__": + print("Testing Spotify connection...") + test_spotify_connection() + print() + + print("Testing Emby connection...") + test_emby_connection() + + print("Retrieving Emby profiles...") + get_emby_profiles() \ No newline at end of file diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..5805331 --- /dev/null +++ b/src/main.py @@ -0,0 +1,31 @@ +from src.clients.emby_client import Emby +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) + +# Create an array of target usernames +target_usernames = ["Alyssa", "Dalton", "Laura", "Zuko", "Azula","Brittany","Adam"] + +# Get all the playlists for the source user +playlists = source_emby.get_playlists() + +# Iterate over each playlist +for playlist in playlists: + # Iterate over the target usernames + for target_username in target_usernames: + # Check if the target username is in the playlist name + 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, '') + + # Create an instance of the PlaylistService + playlist_service = PlaylistService(source_emby, target_emby) + + # Copy the playlist to the target user + new_playlist = playlist_service.copy_playlist_by_usernames(Config.EMBY_USERNAME, target_username, playlist["Id"]) + print(f"Playlist '{playlist['Name']}' copied successfully to user '{target_username}'. New playlist ID: {new_playlist['Id']}") + except ValueError as e: + print(f"Error copying playlist '{playlist['Name']}' to user '{target_username}': {str(e)}") \ No newline at end of file diff --git a/src/mesh-shows.py b/src/mesh-shows.py new file mode 100644 index 0000000..8eee2f2 --- /dev/null +++ b/src/mesh-shows.py @@ -0,0 +1,49 @@ +from src.clients.emby_client import Emby, EmbyLibraryItemType + +# Array of TV shows +shows = [ + {"id": "show1-id", "name": "First Show"}, + {"id": "show2-id", "name": "Second Show"}, + {"id": "show3-id", "name": "Third Show"} +] + +def mesh_shows(emby_client): + all_episodes = [] + for show in shows: + seasons = emby_client.get_seasons(show["id"]) + for season in seasons: + episodes = emby_client.get_episodes(show["id"], season["Id"]) + all_episodes.append(episodes) + + max_episodes = max(len(episodes) for episodes in all_episodes) + + playlist = emby_client.create_playlist("Meshed Shows", EmbyLibraryItemType.VIDEO.value) + playlist_id = playlist["Id"] + + for i in range(max_episodes): + for j, episodes in enumerate(all_episodes): + if i < len(episodes): + episode = episodes[i] + emby_client.add_item_to_playlist(playlist_id, episode["Id"]) + print(f"Adding {shows[j]['name']} S{episode['ParentIndexNumber']:02}E{episode['IndexNumber']:02} to the playlist") + + # Balance the shows based on the number of episodes + remaining_episodes = len(episodes) - (i + 1) + other_remaining_episodes = [len(eps) - (i + 1) for eps in all_episodes[:j] + all_episodes[j+1:]] + if remaining_episodes < min(other_remaining_episodes): + # Add additional episodes of the current show + extra_episodes = min(other_remaining_episodes) - remaining_episodes + for k in range(extra_episodes): + if i + k + 1 < len(episodes): + extra_episode = episodes[i + k + 1] + emby_client.add_item_to_playlist(playlist_id, extra_episode["Id"]) + print(f"Adding {shows[j]['name']} S{extra_episode['ParentIndexNumber']:02}E{extra_episode['IndexNumber']:02} to the playlist") + + print("Playlist created successfully!") + +def main(): + emby_client = Emby(EMBY_URL, EMBY_USERNAME, EMBY_API_KEY) + mesh_shows(emby_client) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/services/__init__.py b/src/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/__pycache__/__init__.cpython-312.pyc b/src/services/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..deda8f694e6900a7b5197cc0497a3ec657350656 GIT binary patch literal 164 zcmX@j%ge<81Q9-#X(0MBh(HIQS%4zb87dhx8U0o=6fpsLpFwJVIXhd$gche3700+F z=A|n5X6EFi7R4mzr=%t&7N^Ff<|b9@7AF^F7L*jn6c;7O0F{lIY~;;_lhPbtkwwJTx;n#l;n#URE7%Q6rTOFH}PN6q;`KC*z_l|aoLrr9IZgAL@(T|1_`Mr-kaUEoT@=$B)>QF=FOWo zZ@+KGzs6#{1lo4}{QPu?kUwz{G|}(uo`B9O(TK*C$Rd~HIKq?niN@a~n(%=4ymEY1 z%t(LXAj7%6lZ$iJ@sgpJt)e&YN!Wt5-3d^uL?<~;BRO8o3xk*SIpYXFZst% zeM@$!>dLBWlnqN&YlogWWj2_>uVnUvxIuOXUu=lCdUlSEG{lGUp2wXug^mGmUZXM8Z82X?^rumh*ZXpm*`>=8uqXUanIQE7&`xpJzT(z6%o+b%}9uZ?YW%e zJFBF+cq3uqEyWt}jziwv>!AKSt}gBI5RjA4cAUSzt)wpf6 zl4DMCY(s4(CmWHS@yVr literal 0 HcmV?d00001 diff --git a/src/services/playlist_service.py b/src/services/playlist_service.py new file mode 100644 index 0000000..ed94bc5 --- /dev/null +++ b/src/services/playlist_service.py @@ -0,0 +1,38 @@ +from src.clients.emby_client import EmbyClient + +class PlaylistService: + def __init__(self, source_emby: EmbyClient, target_emby: EmbyClient): + self.emby = source_emby + self.target_emby = target_emby + + def copy_playlist(self, source_user_id: int , target_user_id: int, playlist_id: str): + # Get the playlist details + playlist = self.emby.get_list(playlist_id) + + print(f"Copying playlist {playlist_id} to {target_user_id}") + # print(playlist) + + # Create a new playlist for the target user + new_playlist = self.target_emby.create_playlist(playlist["Name"], playlist["Type"], user_id=target_user_id) + + # Get the items from the source playlist + playlist_items, _ = self.emby.get_list_items(playlist_id) + + # Add the items to the new playlist + for item in playlist_items: + self.target_emby.add_item_to_playlist(new_playlist["Id"], item["Id"]) + + return new_playlist + + def copy_playlist_by_usernames(self, source_username: str, target_username: str, playlist_id: str): + + source_user = self.emby.get_user_by_username(source_username) + target_user = self.target_emby.get_user_by_username(target_username) + + if source_user is None: + raise ValueError(f"Source user '{source_username}' not found.") + if target_user is None: + raise ValueError(f"Target user '{target_username}' not found.") + + # Call the copy_playlist method with the user IDs + return self.copy_playlist(source_user['Id'], target_user["Id"], playlist_id) \ No newline at end of file diff --git a/src/tests/__init__.py b/src/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/__pycache__/__init__.cpython-312.pyc b/src/tests/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a5994007c0b0896a92cf42705e9388084591bdec GIT binary patch literal 161 zcmX@j%ge<81kt9JX(0MBh(HIQS%4zb87dhx8U0o=6fpsLpFwJVIXGLzgche3700+F z=A|n5X6EFi7R4mzr=%t&7N^Ff<|b9@7AF^F7L*jn6c;7Ol%y5|S@H3id6^~g@p=W7 hzc_4i^HWN5QtgUZfd(=HaWRPTk(rT^v4|PS0su;2C=&nx literal 0 HcmV?d00001 diff --git a/src/tests/__pycache__/emby_integration_test.cpython-312.pyc b/src/tests/__pycache__/emby_integration_test.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f35b0fde7104adaec5c86e325cd92495bd7a0d79 GIT binary patch literal 8866 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 Ozw`WEJ3-Z}i}^Q}Jf|Z7 literal 0 HcmV?d00001 diff --git a/src/tests/__pycache__/emby_test.cpython-312.pyc b/src/tests/__pycache__/emby_test.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bc49e3c0a660e5e02f11227c9ae563daaa0d456e GIT binary patch literal 4408 zcmbtXU2GHC6}~f`vB!x`95y6&NWe+hC8i|C1Sm_%vLOj2urckDl#MrNYCIFx!9Up< zhhR5IbVb!vLTa}!0YL>1eaMPRWuHo)y5*@-UuK#hN7JlYsr$lPQno_ksps7Bj3>dM zN*zo0o_qf0jPG~OJ@>DEe;t8G{_eX|Ye4-A7uLg9i08L}SR@jWm<&na%x0J@n_#o9 zgp0woE5l{^1W#!$44?5IzM!obiymwNuDd+gecot0=msr z9gBKcAsA9*icCa!hU<8QHTaoiA@zyj9Zg}Vru7iKuMq(VXY_IBCRGI-_H4r4)alhIA zHP-c(|00R7af2O^B0dE_+7O0EPfm#AAAVresj_laR>WB)WB5((`QZ=a1Ea%+FblF= zGAkQCDj4{|hSi!X&kvUbD2#u*V%b?o)OVGePDx~_7(25Rlq81oUDYt?gAT+Lr@_LUW+&HvJ;wr zSn&rE#NkH8G7yGG!D?+ZA@r!NL#Nbk1F)7D6I#6v!wvG!Q24>A zrBSWx*yA64bx}KhUh5pwg5wMR)$YTZ5L^-7UTtn&On;s(H%Im6=yG#xp?+2Hm4%=# z1n<3d`{U(MWGS{H^w`YXJ0JY;&aNf59*RC{UlC5!7CaSBC_#vzHu`=C5(|A2caN2L z$%XU(hrsFq^EwPxkw@`nBND=?JXO4HLXIb{w1S4x27}?gl)nafA7Pc9xa7u)GqTc- zVq8afC5$x1Nb_clh?j~Yt!oV?R7TWzz7WsnWTg`=O}Hp~(EM!}j5^1jZ-&Lr8vAZ} zFYS2~IxH>B67?bs6eLTdUmexEKF}^mTJEYIpVK<8X~CisCObll6Q56%cO2Gt9DbBt z-Z8XLN4LIB7us$oR)p><5LzFc(nH@_y7*N19tAosL+CJ)%IRj05L&_nvy<{^z) zNFTe(Kx2Wm>mJmrS{15#Y}0yHsWOBV>>49+Ruwu-uDbw%Zm59FysK*I(DgWUIjB_j z0c*tt?Sp;jRe2DjVBzRG4|e{l%%bLb$xAC1V`)-s*4E_B+UC&YrtKEO@`%+^a#wYr zjfCIp*G1ArVnk);ScXgx27b4;Uvo{6Ic6)rNKAgDE^hea^4y?Z{fvg3JXb|4oV{jt zDA-7p_SW8iOpY|xW5i?uvk0{fH}cX_DOTG`k~4BariS8_lFvG7uo3}#GYbU;6UN}C z)qKuONW_zwm5nJyPJ)YSyfpue7BefwR1%H_NzCU`vN)G6d=hb+b-a;QZ6|2WG;Ox) z`=+lk!j%7}czYlw6&oWg_*Thprn&t}jHTV1)A|*K4s>R~aD#jk?6_MG+1qzgJM*!= zZ*sw37FuFCM-mKh$Ot}cUCTn3E_6MJuLymsLZ~dnbRqU=><{9XqIPIl z8_sGurJO72xssOs2{ftm%o!G@v+Ozc1x^>(4RZqK8TKQT^-Zwy+Fi(cJbL%d`wbD2I7ke+_ AasU7T literal 0 HcmV?d00001 diff --git a/src/tests/emby_integration_test.py b/src/tests/emby_integration_test.py new file mode 100644 index 0000000..1ccc5ff --- /dev/null +++ b/src/tests/emby_integration_test.py @@ -0,0 +1,172 @@ +import unittest +from src.clients.emby_client import Emby +from src.config import Config + + +class TestEmbyIntegration(unittest.TestCase): + + def setUp(self): + self.server_url = Config.EMBY_URL + 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) + + def test_get_collections(self): + collections = self.emby.get_collections() + self.assertIsInstance(collections, list) + # Assert that the collections have the expected properties + for collection in collections: + self.assertIn("Name", collection) + self.assertIn("Id", collection) + + def test_add_and_remove_items_from_collection(self): + # Get the ID of the first item in the movies library + movies_library = next((library for library in self.emby.get_libraries() if library["Name"] == "Movies"), None) + if movies_library is None: + self.skipTest("Movies library not found") + + movies, _ = self.emby.get_items_from_library(movies_library["Name"]) + if not movies: + self.skipTest("No movies found in the library") + + item_id = movies[0]["Id"] + + # Create a new collection + collection_name = "Test Collection" + collection_type = "movies" + collection = self.emby.create_collection(collection_name, collection_type) + self.assertEqual(collection["Name"], collection_name) + self.assertIsNotNone(collection["Id"]) + + # Add the item to the collection + self.emby.add_item_to_collection(collection["Id"], item_id) + + # Verify that the item is added to the collection + collection_items, _ = self.emby.get_collection_items(collection["Id"]) + self.assertIn(item_id, [item["Id"] for item in collection_items]) + + # Delete the item from the collection + self.emby.delete_item_from_collection(collection["Id"], item_id) + + # Verify that the item is deleted from the collection + collection_items, _ = self.emby.get_collection_items(collection["Id"]) + self.assertNotIn(item_id, [item["Id"] for item in collection_items]) + + def test_create_collection(self): + # Create a new collection + collection_name = "Test Collection" + collection_type = "Movie" + collection = self.emby.create_collection(collection_name, collection_type) + self.assertEqual(collection["Name"], collection_name) + self.assertIsNotNone(collection["Id"]) + + # Delete the collection + self.emby.delete_collection(collection["Id"]) + + # Verify that the collection is deleted + collections = self.emby.get_collections() + self.assertNotIn(collection["Id"], [c["Id"] for c in collections]) + + # Add more integration tests for other methods + + def test_create_and_delete_playlist(self): + # Get the ID of the first item in the movies library + movies_library = next((library for library in self.emby.get_libraries() if library["Name"] == "Movies"), None) + if movies_library is None: + self.skipTest("Movies library not found") + + movies, _ = self.emby.get_items_from_library(movies_library["Name"]) + if not movies: + self.skipTest("No movies found in the library") + + item_id = movies[0]["Id"] + + # Create a new playlist + playlist_name = "Test Playlist" + playlist_type = "movies" + playlist = self.emby.create_playlist(playlist_name, playlist_type) + self.assertEqual(playlist["Name"], playlist_name) + self.assertIsNotNone(playlist["Id"]) + + # Add the item to the playlist + self.emby.add_item_to_playlist(playlist["Id"], item_id) + + # Verify that the item is added to the playlist + playlist_items, _ = self.emby.get_list_items(playlist["Id"]) + self.assertIn(item_id, [item["Id"] for item in playlist_items]) + + # Delete the item from the playlist + self.emby.delete_item_from_playlist(playlist["Id"], item_id) + + # Verify that the item is deleted from the playlist + playlist_items, _ = self.emby.get_list_items(playlist["Id"]) + self.assertNotIn([item_id], [item["Id"] for item in playlist_items]) + + # Delete the playlist + self.emby.delete_playlist(playlist["Id"]) + + # Verify that the playlist is deleted + playlists = self.emby.get_playlists() + self.assertNotIn(playlist["Id"], [p["Id"] for p in playlists]) + + def test_search_for_track_existing(self): + # Test searching for a track that exists in the Emby library + track_name = "Away from the Sun" + artist_name = "3 Doors Down" + search_results = self.emby.search_for_track(track_name, artist_name) + print(search_results) + self.assertIsNotNone(search_results) + self.assertGreater(len(search_results), 0) + print(len(search_results)) + has_match = False + for track in search_results: + if track["Name"].lower() == track_name.lower(): + for artist in track["Artists"]: + if artist.lower() == artist_name.lower(): + has_match = True + self.assertTrue(has_match) + + def test_search_for_track_nonexistent(self): + # Test searching for a track that doesn't exist in the Emby library + track_name = "Nonexistent Track" + artist_name = "Nonexistent Artist" + search_results = self.emby.search_for_track(track_name, artist_name) + self.assertIsNotNone(search_results) + self.assertEqual(len(search_results), 0) + + # def test_search_for_track_partial_match(self): + # # Test searching for a track with partial track name and artist name + # track_name = "away from" + # artist_name = "3 doors" + # search_results = self.emby.search_for_track(track_name, artist_name) + # self.assertIsNotNone(search_results) + # self.assertGreater(len(search_results), 0) + # for track in search_results: + # self.assertIn(track_name.lower(), track["Name"].lower()) + # self.assertIn(artist_name.lower(), [artist["Name"].lower() for artist in track["Artists"]]) + + def test_search_for_track_empty_query(self): + # Test searching for a track with empty track name and artist name + track_name = "" + artist_name = "" + search_results = self.emby.search_for_track(track_name, artist_name) + self.assertIsNotNone(search_results) + # Depending on your Emby server, empty search query may return all tracks or no tracks + # Adjust the assertion based on your expected behavior + # self.assertGreater(len(search_results), 0) + # self.assertEqual(len(search_results), 0) + + def test_search_for_track_special_characters(self): + # Test searching for a track with special characters in track name and artist name + track_name = "Track with !@#$%^&*()" + artist_name = "Artist with !@#$%^&*()" + search_results = self.emby.search_for_track(track_name, artist_name) + self.assertIsNotNone(search_results) + # Depending on your Emby server, special characters may affect the search results + # Adjust the assertions based on your expected behavior + # self.assertGreater(len(search_results), 0) + # self.assertEqual(len(search_results), 0) + +if __name__ == '__main__': + unittest.main() diff --git a/src/tests/emby_test.py b/src/tests/emby_test.py new file mode 100644 index 0000000..b4f581c --- /dev/null +++ b/src/tests/emby_test.py @@ -0,0 +1,55 @@ +import unittest +from unittest.mock import patch, MagicMock +from src.clients.emby_client import Emby +from src.config import Config + +class TestEmby(unittest.TestCase): + + def setUp(self): + self.server_url = Config.EMBY_URL + self.username = Config.EMBY_USERNAME + self.api_key = Config.EMBY_API_KEY + self.emby = Emby(self.server_url, self.username, self.api_key) + self.emby.user = {"Id": Config.EMBY_USER_ID} + self.emby.user_id = Config.EMBY_USER_ID + + @patch('src.clients.emby.Emby._get_request') + def test_get_collections(self, mock_get_request): + mock_response = {"Items": [{"Name": "Collection1"}, {"Name": "Collection2"}]} + mock_get_request.return_value = mock_response + collections = self.emby.get_collections() + self.assertEqual(len(collections), 2) + self.assertEqual(collections[0]["Name"], "Collection1") + self.assertEqual(collections[1]["Name"], "Collection2") + + @patch('src.clients.emby.Emby._get_request') + def test_get_collection_by_name(self, mock_get_request): + mock_response = {"Items": [{"Name": "Collection1", "Type": "boxset"}, {"Name": "Collection2", "Type": "boxset"}]} + mock_get_request.return_value = mock_response + collection = self.emby.get_collection_by_name("Collection1") + self.assertIsNotNone(collection) + self.assertEqual(collection["Name"], "Collection1") + + @patch('src.clients.emby.Emby._post_request') + def test_create_collection(self, mock_post_request): + mock_response = MagicMock() + mock_response.json.return_value = {"Name": "NewCollection", "Id": "newcollectionid"} + mock_post_request.return_value = mock_response + + with patch('src.clients.emby.Emby.get_items_by_type') as mock_get_items_by_type: + mock_get_items_by_type.return_value = [{"Id": "itemid"}] + with patch('src.clients.emby.Emby.delete_item_from_collection') as mock_delete_item_from_collection: + collection = self.emby.create_collection("NewCollection", "boxset") + self.assertEqual(collection["Name"], "NewCollection") + self.assertEqual(collection["Id"], "newcollectionid") + mock_delete_item_from_collection.assert_called_once_with("newcollectionid", "itemid") + + @patch('src.clients.emby.Emby._post_request') + def test_delete_collection(self, mock_post_request): + self.emby.delete_collection("collectionid") + mock_post_request.assert_called_once_with(f"{self.server_url}/emby/Items/collectionid/Delete?api_key={self.api_key}&X-Emby-Token={self.api_key}") + + # Add more test methods for other functions in the Emby class + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/src/types/__init__.py b/src/types/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/__pycache__/__init__.cpython-312.pyc b/src/utils/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..50b850c3ce6b4d737e35f46522844eb64587f379 GIT binary patch literal 161 zcmX@j%ge<81RSxJX(0MBh(HIQS%4zb87dhx8U0o=6fpsLpFwJVIXGLzgche3700+F z=A|n5X6EFi7R4mzr=%t&7N^Ff<|b9@7AF^F7L*jn6c;7Ol$K=X6vxEJXXa&=#K-Fu iRQ}?y$<0qG%}KQ@Vg(w=2*kx8#z$sGM#ds$APWF6JSXM= literal 0 HcmV?d00001 diff --git a/src/utils/__pycache__/string.cpython-312.pyc b/src/utils/__pycache__/string.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..867cf7a38144d70171a0906057428d37ca4bf488 GIT binary patch literal 1045 zcmb7D&ubGw6rS1L)U-(($)Ra!l@SzOA+48ED^lYn}Jz<&?Nv`$VD#pQ3)C79&(LcFYM8baXO<)B1y5EY&sTgv(%cj*6IS;DxE6{|Vw?TeaC~Aec8x7`p!2~t zHTO3==DztbSA`_&wSY%n7?3C=QdGjaAVC-unP1&tL9HUB#|htHlJQ7L zMOfex9tL$GBhB!`XF_tO#LP+Em-QF*_W0%f{Qjd(ZuWg{?p;6`lW!T7fE7KSldOU=wGU8QUE3bog+$}`Yb4uP``nbvqX!Lb#I=TTn( z;W~P!^c5jQX5kS}_6F*xs^A{;onLcoUOKJQU(+&BMHRvApf82VR{d4|b+BJPyx1ws z?`1#rPTJ>Z5A%n#Q@FjC{g9h#r>Eo?FqZ68oOYamRfOZjBaTxE-I}j))^VQJnBR-Y zQOK2faBon;OWs`Szc6tcDE%?J=*X~A*SlFOHQh_pp@ASt{