diff --git a/.env.sample b/.env.sample index aaf56b3..8a2f017 100644 --- a/.env.sample +++ b/.env.sample @@ -1,43 +1,68 @@ +# Global Settings + ## Do not mark any shows/movies as played and instead just output to log if they would of been marked. DRYRUN = "True" + ## Additional logging information -DEBUG = "True" +DEBUG = "False" + ## Debugging level, "info" is default, "debug" is more verbose DEBUG_LEVEL = "info" + ## How often to run the script in seconds SLEEP_DURATION = "3600" + ## Log file where all output will be written to LOGFILE = "log.log" -## Map usernames between plex and jellyfin in the event that they are different, order does not matter -#USER_MAPPING = { "testuser2": "testuser3" } -## Map libraries between plex and jellyfin in the even that they are different, order does not matter -#LIBRARY_MAPPING = { "Shows": "TV Shows" } +## Map usernames between servers in the event that they are different, order does not matter +## Comma seperated for multiple options +#USER_MAPPING = { "testuser2": "testuser3", "testuser1":"testuser4" } -## Recommended to use token as it is faster to connect as it is direct to the server instead of going through the plex servers -## URL of the plex server, use hostname or IP address if the hostname is not resolving correctly -## Comma seperated list for multiple servers -PLEX_BASEURL = "http://localhost:32400" -## Plex token https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/ -PLEX_TOKEN = "SuperSecretToken" -## If not using plex token then use username and password of the server admin along with the servername -#PLEX_USERNAME = "" -#PLEX_PASSWORD = "" -#PLEX_SERVERNAME = "Plex Server" -## Skip hostname validation for ssl certificates. -SSL_BYPASS = "False" - -## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly -## Comma seperated list for multiple servers -JELLYFIN_BASEURL = "http://localhost:8096" -## Jellyfin api token, created manually by logging in to the jellyfin server admin dashboard and creating an api key -JELLYFIN_TOKEN = "SuperSecretToken" - +## Map libraries between servers in the even that they are different, order does not matter +## Comma seperated for multiple options +#LIBRARY_MAPPING = { "Shows": "TV Shows", "Movie": "Movies" } ## Blacklisting/Whitelisting libraries, library types such as Movies/TV Shows, and users. Mappings apply so if the mapping for the user or library exist then both will be excluded. +## Comma seperated for multiple options #BLACKLIST_LIBRARY = "" #WHITELIST_LIBRARY = "" #BLACKLIST_LIBRARY_TYPE = "" #WHITELIST_LIBRARY_TYPE = "" #BLACKLIST_USERS = "" WHITELIST_USERS = "testuser1,testuser2" + + + +# Plex + +## Recommended to use token as it is faster to connect as it is direct to the server instead of going through the plex servers +## URL of the plex server, use hostname or IP address if the hostname is not resolving correctly +## Comma seperated list for multiple servers +PLEX_BASEURL = "http://localhost:32400, https://nas:32400" + +## Plex token https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/ +## Comma seperated list for multiple servers +PLEX_TOKEN = "SuperSecretToken, SuperSecretToken2" + +## If not using plex token then use username and password of the server admin along with the servername +## Comma seperated for multiple options +#PLEX_USERNAME = "PlexUser, PlexUser2" +#PLEX_PASSWORD = "SuperSecret, SuperSecret2" +#PLEX_SERVERNAME = "Plex Server1, Plex Server2" + +## Skip hostname validation for ssl certificates. +## Set to True if running into ssl certificate errors +SSL_BYPASS = "False" + + + +# Jellyfin + +## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly +## Comma seperated list for multiple servers +JELLYFIN_BASEURL = "http://localhost:8096, http://nas:8096" + +## Jellyfin api token, created manually by logging in to the jellyfin server admin dashboard and creating an api key +## Comma seperated list for multiple servers +JELLYFIN_TOKEN = "SuperSecretToken, SuperSecretToken2" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24535c1..c60c3fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: pytest: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: "Install dependencies" run: pip install -r requirements.txt && pip install -r test/requirements.txt @@ -26,7 +26,7 @@ jobs: needs: pytest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Docker meta id: docker_meta @@ -45,14 +45,14 @@ jobs: type=sha - name: Set up QEMU - uses: docker/setup-qemu-action@v1 + uses: docker/setup-qemu-action@v2 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@v2 - name: Login to DockerHub if: "${{ steps.docker_meta.outcome == 'success' }}" - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} @@ -60,7 +60,7 @@ jobs: - name: Build id: build if: "${{ steps.docker_meta.outcome == 'skipped' }}" - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v3 with: context: . file: ./Dockerfile @@ -71,7 +71,7 @@ jobs: - name: Build Push id: build_push if: "${{ steps.docker_meta.outcome == 'success' }}" - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v3 with: context: . file: ./Dockerfile diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..de288e1 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python.formatting.provider": "black" +} \ No newline at end of file diff --git a/README.md b/README.md index c20f450..084acaf 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,84 @@ [![Codacy Badge](https://app.codacy.com/project/badge/Grade/26b47c5db63942f28f02f207f692dc85)](https://www.codacy.com/gh/luigi311/JellyPlex-Watched/dashboard?utm_source=github.com&utm_medium=referral&utm_content=luigi311/JellyPlex-Watched&utm_campaign=Badge_Grade) -Sync watched between jellyfin and plex +Sync watched between jellyfin and plex locally ## Description -Keep in sync all your users watched history between jellyfin and plex servers locally. This uses the imdb ids and any other matching id to find the correct episode/movie between the two. This is not perfect but it works for most cases. You can use this for as many servers as you want by enterying multiple options in the .env plex/jellyfin section seperated by commas. +Keep in sync all your users watched history between jellyfin and plex servers locally. This uses file names and provider ids to find the correct episode/movie between the two. This is not perfect but it works for most cases. You can use this for as many servers as you want by enterying multiple options in the .env plex/jellyfin section seperated by commas. ## Configuration +```bash +# Global Settings + +## Do not mark any shows/movies as played and instead just output to log if they would of been marked. +DRYRUN = "True" + +## Additional logging information +DEBUG = "False" + +## Debugging level, "info" is default, "debug" is more verbose +DEBUG_LEVEL = "info" + +## How often to run the script in seconds +SLEEP_DURATION = "3600" + +## Log file where all output will be written to +LOGFILE = "log.log" + +## Map usernames between servers in the event that they are different, order does not matter +## Comma seperated for multiple options +USER_MAPPING = { "testuser2": "testuser3", "testuser1":"testuser4" } + +## Map libraries between servers in the even that they are different, order does not matter +## Comma seperated for multiple options +LIBRARY_MAPPING = { "Shows": "TV Shows", "Movie": "Movies" } + +## Blacklisting/Whitelisting libraries, library types such as Movies/TV Shows, and users. Mappings apply so if the mapping for the user or library exist then both will be excluded. +## Comma seperated for multiple options +BLACKLIST_LIBRARY = "" +WHITELIST_LIBRARY = "" +BLACKLIST_LIBRARY_TYPE = "" +WHITELIST_LIBRARY_TYPE = "" +BLACKLIST_USERS = "" +WHITELIST_USERS = "testuser1,testuser2" + + + +# Plex + +## Recommended to use token as it is faster to connect as it is direct to the server instead of going through the plex servers +## URL of the plex server, use hostname or IP address if the hostname is not resolving correctly +## Comma seperated list for multiple servers +PLEX_BASEURL = "http://localhost:32400, https://nas:32400" + +## Plex token https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/ +## Comma seperated list for multiple servers +PLEX_TOKEN = "SuperSecretToken, SuperSecretToken2" + +## If not using plex token then use username and password of the server admin along with the servername +## Comma seperated for multiple options +#PLEX_USERNAME = "PlexUser, PlexUser2" +#PLEX_PASSWORD = "SuperSecret, SuperSecret2" +#PLEX_SERVERNAME = "Plex Server1, Plex Server2" + +## Skip hostname validation for ssl certificates. +## Set to True if running into ssl certificate errors +SSL_BYPASS = "False" + + + +# Jellyfin + +## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly +## Comma seperated list for multiple servers +JELLYFIN_BASEURL = "http://localhost:8096, http://nas:8096" + +## Jellyfin api token, created manually by logging in to the jellyfin server admin dashboard and creating an api key +## Comma seperated list for multiple servers +JELLYFIN_TOKEN = "SuperSecretToken, SuperSecretToken2" +``` ## Installation diff --git a/src/functions.py b/src/functions.py index 83acc6c..500098c 100644 --- a/src/functions.py +++ b/src/functions.py @@ -168,8 +168,13 @@ def check_skip_logic( ): skip_reason = None - if library_type.lower() in blacklist_library_type: - skip_reason = "is blacklist_library_type" + if isinstance(library_type, (list, tuple, set)): + for library_type_item in library_type: + if library_type_item.lower() in blacklist_library_type: + skip_reason = "is blacklist_library_type" + else: + if library_type.lower() in blacklist_library_type: + skip_reason = "is blacklist_library_type" if library_title.lower() in [x.lower() for x in blacklist_library]: skip_reason = "is blacklist_library" @@ -182,8 +187,13 @@ def check_skip_logic( skip_reason = "is blacklist_library" if len(whitelist_library_type) > 0: - if library_type.lower() not in whitelist_library_type: - skip_reason = "is not whitelist_library_type" + if isinstance(library_type, (list, tuple, set)): + for library_type_item in library_type: + if library_type_item.lower() not in whitelist_library_type: + skip_reason = "is not whitelist_library_type" + else: + if library_type.lower() not in whitelist_library_type: + skip_reason = "is not whitelist_library_type" # if whitelist is not empty and library is not in whitelist if len(whitelist_library) > 0: diff --git a/src/jellyfin.py b/src/jellyfin.py index 9658427..d5fe092 100644 --- a/src/jellyfin.py +++ b/src/jellyfin.py @@ -92,7 +92,7 @@ class Jellyfin: user_watched[user_name][library_title] = [] watched = await self.query( f"/Users/{user_id}/Items" - + f"?ParentId={library_id}&Filters=IsPlayed&Fields=ItemCounts,ProviderIds,MediaSources", + + f"?ParentId={library_id}&Filters=IsPlayed&IncludeItemTypes=Movie&Recursive=True&Fields=ItemCounts,ProviderIds,MediaSources", "get", session, ) @@ -155,7 +155,7 @@ class Jellyfin: # Retrieve a list of watched TV shows watched_shows = await self.query( f"/Users/{user_id}/Items" - + f"?ParentId={library_id}&isPlaceHolder=false&Fields=ProviderIds,Path,RecursiveItemCount", + + f"?ParentId={library_id}&isPlaceHolder=false&IncludeItemTypes=Series&Recursive=True&Fields=ProviderIds,Path,RecursiveItemCount", "get", session, ) @@ -339,7 +339,7 @@ class Jellyfin: task = asyncio.ensure_future( self.query( f"/Users/{user_id}/Items" - + f"?ParentId={library_id}&Filters=IsPlayed&limit=1", + + f"?ParentId={library_id}&Filters=IsPlayed&Recursive=True&excludeItemTypes=Folder&limit=100", "get", session, identifiers=identifiers, @@ -357,11 +357,18 @@ class Jellyfin: library_id = watched["Identifiers"]["library_id"] library_title = watched["Identifiers"]["library_title"] - library_type = watched["Items"][0]["Type"] + # Get all library types excluding "Folder" + types = set( + [ + x["Type"] + for x in watched["Items"] + if x["Type"] in ["Movie", "Series"] + ] + ) skip_reason = check_skip_logic( library_title, - library_type, + types, blacklist_library, whitelist_library, blacklist_library_type, @@ -376,15 +383,29 @@ class Jellyfin: ) continue - # Get watched for user - task = asyncio.ensure_future( - self.get_user_library_watched( - user_name, user_id, library_type, library_id, library_title + # If there are multiple types in library raise error + if types is None or len(types) < 1: + logger( + f"Jellyfin: Skipping Library {library_title} not a single type: {types}", + 1, ) - ) - tasks_watched.append(task) + continue + + for library_type in types: + # Get watched for user + task = asyncio.ensure_future( + self.get_user_library_watched( + user_name, + user_id, + library_type, + library_id, + library_title, + ) + ) + tasks_watched.append(task) watched = await asyncio.gather(*tasks_watched, return_exceptions=True) + return watched except Exception as e: logger(f"Jellyfin: Failed to get users watched, Error: {e}", 2) @@ -450,8 +471,8 @@ class Jellyfin: if videos_movies_ids: jellyfin_search = await self.query( f"/Users/{user_id}/Items" - + f"?SortBy=SortName&SortOrder=Ascending&Recursive=false&ParentId={library_id}" - + "&isPlayed=false&Fields=ItemCounts,ProviderIds,MediaSources", + + f"?SortBy=SortName&SortOrder=Ascending&Recursive=True&ParentId={library_id}" + + "&isPlayed=false&Fields=ItemCounts,ProviderIds,MediaSources&IncludeItemTypes=Movie", "get", session, ) @@ -504,8 +525,8 @@ class Jellyfin: if videos_shows_ids and videos_episodes_ids: jellyfin_search = await self.query( f"/Users/{user_id}/Items" - + f"?SortBy=SortName&SortOrder=Ascending&Recursive=false&ParentId={library_id}" - + "&isPlayed=false&Fields=ItemCounts,ProviderIds,Path", + + f"?SortBy=SortName&SortOrder=Ascending&Recursive=True&ParentId={library_id}" + + "&isPlayed=false&Fields=ItemCounts,ProviderIds,Path&IncludeItemTypes=Series", "get", session, ) diff --git a/src/main.py b/src/main.py index 153ec71..cb0442a 100644 --- a/src/main.py +++ b/src/main.py @@ -113,12 +113,12 @@ def setup_users( if len(output_server_1_users) == 0: raise Exception( - f"No users found for server 1, users found {users} filtered users {users_filtered}" + f"No users found for server 1 {server_1_type}, users found {users}, filtered users {users_filtered}, server 1 users {server_1_connection.users}" ) if len(output_server_2_users) == 0: raise Exception( - f"No users found for server 2, users found {users} filtered users {users_filtered}" + f"No users found for server 2 {server_2_type}, users found {users} filtered users {users_filtered}, server 2 users {server_2_connection.users}" ) logger(f"Server 1 users: {output_server_1_users}", 1) diff --git a/src/plex.py b/src/plex.py index a91b81d..7b1d036 100644 --- a/src/plex.py +++ b/src/plex.py @@ -262,6 +262,7 @@ class Plex: password=None, servername=None, ssl_bypass=False, + session=None, ): self.baseurl = baseurl self.token = token @@ -269,21 +270,20 @@ class Plex: self.password = password self.servername = servername self.ssl_bypass = ssl_bypass - self.plex = self.login(self.baseurl, self.token, ssl_bypass) + if ssl_bypass: + # Session for ssl bypass + session = requests.Session() + # By pass ssl hostname check https://github.com/pkkid/python-plexapi/issues/143#issuecomment-775485186 + session.mount("https://", HostNameIgnoringAdapter()) + self.session = session + self.plex = self.login(self.baseurl, self.token) self.admin_user = self.plex.myPlexAccount() self.users = self.get_users() - def login(self, baseurl, token, ssl_bypass=False): + def login(self, baseurl, token): try: if baseurl and token: - # Login via token - if ssl_bypass: - session = requests.Session() - # By pass ssl hostname check https://github.com/pkkid/python-plexapi/issues/143#issuecomment-775485186 - session.mount("https://", HostNameIgnoringAdapter()) - plex = PlexServer(baseurl, token, session=session) - else: - plex = PlexServer(baseurl, token) + plex = PlexServer(baseurl, token, session=self.session) elif self.username and self.password and self.servername: # Login via plex account account = MyPlexAccount(self.username, self.password) @@ -333,7 +333,6 @@ class Plex: user_plex = self.login( self.plex._baseurl, user.get_token(self.plex.machineIdentifier), - self.ssl_bypass, ) libraries = user_plex.library.sections() @@ -397,8 +396,17 @@ class Plex: if self.admin_user == user: user_plex = self.plex else: + if isinstance(user, str): + logger( + f"Plex: {user} is not a plex object, attempting to get object for user", + 4, + ) + user = self.plex.myPlexAccount().user(user) + user_plex = PlexServer( - self.plex._baseurl, user.get_token(self.plex.machineIdentifier) + self.plex._baseurl, + user.get_token(self.plex.machineIdentifier), + session=self.session, ) for library, videos in libraries.items():