From d1ee90e6ab8faa3886da134345a2e5328ad14135 Mon Sep 17 00:00:00 2001 From: MMA <58857539+TheGeeKing@users.noreply.github.com> Date: Mon, 7 Aug 2023 01:44:46 +0200 Subject: [PATCH] TautulliPython2Trakt v1.1.0 ### Added - This CHANGELOG.md file. - GitHub Templates for Bug Reports and Feature Requests. - Syncing behavior section in the README.md file. - More info for the `-PlexUser` argument in the README.md file and when using the `-h` argument. ### Fixed - Sync collections now correctly works. - Sentences in README.md file now go correctly to the next line and grammar mistakes. - Syncing collections now correctly works. - Now correctly log the arguments passed before calling `subprocess.check_output()`. - Fixed some puctuation mistakes. ### Changed - Argument for the recently added in the README.md file. - Syncing collection can now either find the owner username, sync all users found in the env, or sync a list of users. - Sorted imports. - Now gets `HEADERS` from a function in utilities.py for less redundancy. ### Removed - Unused import os in scrobble.py. - Unused `arguments_string` variable in the TautulliPython2Trakt.py file. --- .github/ISSUE_TEMPLATE/BUG-REPORT.yaml | 65 +++++++ .github/ISSUE_TEMPLATE/FEATURE-REQUEST.yaml | 24 +++ CHANGELOG.md | 51 ++++++ README.md | 20 +- TautulliPython2Trakt.py | 8 +- scrobble.py | 1 - sync_collections.py | 191 +++++++++++++++----- utilities.py | 9 + 8 files changed, 308 insertions(+), 61 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/BUG-REPORT.yaml create mode 100644 .github/ISSUE_TEMPLATE/FEATURE-REQUEST.yaml create mode 100644 CHANGELOG.md diff --git a/.github/ISSUE_TEMPLATE/BUG-REPORT.yaml b/.github/ISSUE_TEMPLATE/BUG-REPORT.yaml new file mode 100644 index 0000000..2f3c5c9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/BUG-REPORT.yaml @@ -0,0 +1,65 @@ +name: Bug report +description: Create a new ticket for a bug. +title: "🐛 [BUG]: " +labels: + - bug + - triage +assignees: + - TheGeeKing +body: + - type: textarea + attributes: + label: Description + description: A clear and concise description of the problem. + validations: + required: true + - type: textarea + attributes: + label: Expected behavior + placeholder: A clear and concise description of what you expected to happen. + - type: textarea + attributes: + label: Minimal Reproduction + description: Provide steps to reproduce the problem. + value: |- + 1. Go to '...' + 2. Click on '....' + 3. Scroll down to '....' + 4. See error + validations: + required: true + - type: textarea + attributes: + label: Exception or Error + description: provide error logs + - type: textarea + attributes: + label: Screenshots/Screen recording + description: If applicable, add screenshots/recording to help explain your problem. + placeholder: Add screenshots/recording here + - type: input + attributes: + label: Operating System and Version + placeholder: eg. Windows 10, macOS 10.15, Ubuntu 20.04, etc. + validations: + required: true + - type: input + attributes: + label: Python Version + placeholder: e.g. 3.11 + validations: + required: true + - type: input + attributes: + label: Tautulli version + description: Check Tautulli > Settings > Help & Info. + placeholder: e.g. 2.12.5 + validations: + required: true + - type: input + attributes: + label: Plex Media Server Version + description: Check Plex Server > Settings (not Plex Web) > General. + placeholder: eg. 1.75.0.3920 + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yaml b/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yaml new file mode 100644 index 0000000..9627eb6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yaml @@ -0,0 +1,24 @@ +name: 💡 Feature Request +description: Create a new ticket for a new feature request. +title: "💡 [Feature Request]: " +labels: + - enhancement + - triage +assignees: + - TheGeeKing +body: + - type: textarea + attributes: + label: Description + description: A clear and concise description of the problem or missing capability. + placeholder: I'm always frustrated when [...]. I would like to [...]. + validations: + required: true + - type: textarea + attributes: + label: Describe the solution you'd like + description: If you have a solution in mind, please describe it. + - type: textarea + attributes: + label: Describe alternatives you've considered + description: Have you considered any alternative solutions or workarounds? diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ecfdfd1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,51 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +- Refactor code for requests. +- Use a proper logging module. +- Use proper argument conventions (-h, --help, case) and maybe an argument parser. +- Maybe use a different way to `sys.exit()` known errors. + +## [1.1.0] - 2023-08-07 + +### Added + +- This CHANGELOG.md file. +- GitHub Templates for Bug Reports and Feature Requests. +- Syncing behavior section in the README.md file. +- More info for the `-PlexUser` argument in the README.md file and when using the `-h` argument. + +### Fixed + +- Sync collections now correctly works. +- Sentences in README.md file now go correctly to the next line and grammar mistakes. +- Syncing collections now correctly works. +- Now correctly log the arguments passed before calling `subprocess.check_output()`. +- Fixed some puctuation mistakes. + +### Changed + +- Argument for the recently added in the README.md file. +- Syncing collection can now either find the owner username, sync all users found in the env, or sync a list of users. +- Sorted imports. +- Now gets `HEADERS` from a function in utilities.py for less redundancy. + +### Removed + +- Unused import os in scrobble.py. +- Unused `arguments_string` variable in the TautulliPython2Trakt.py file. + +## [1.0.0] - 2023-05-27 + +### Added + +- Initial release of TautulliPython2Trakt v1.0.0. + +[unreleased]: https://github.com/TheGeeKing/TautulliPython2Trakt/compare/v1.1.0...main +[1.1.0]: https://github.com/TheGeeKing/TautulliPython2Trakt/compare/v1.0.0...v1.1.0 +[1.0.0]: https://github.com/TheGeeKing/TautulliPython2Trakt/releases/tag/v1.0.0 diff --git a/README.md b/README.md index 0b019ba..7cafe3c 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

Tautulli Python 2 Trakt

-

Table of Contents

+## Table of Contents - [Description](#description) - [What it can do](#what-it-can-do) @@ -41,9 +41,11 @@ Python script to scrobble what you watch, sync your collected movies and TV show Download the latest release from [here](https://github.com/TheGeeKing/TautulliPython2Trakt/releases), unzip it and place all files in a folder. -Create a new [application](https://trakt.tv/oauth/applications) Add the follow settings: +Create a new [application](https://trakt.tv/oauth/applications) and add the following settings: -**Name:** `TautulliPython2Trakt` **Redirect uri:** `urn:ietf:wg:oauth:2.0:oob` **Permissions:** `/scrobble` +**Name:** `TautulliPython2Trakt` \ +**Redirect uri:** `urn:ietf:wg:oauth:2.0:oob` \ +**Permissions:** `/scrobble` Run the script: @@ -80,7 +82,7 @@ If you want to collect your movies and TV shows, you need to do the Plex Media S 3. In the `Triggers` section, select `Recently Added`. 4. Put conditions if you want to, like media type, etc. 5. In the `Arguments` tab, put the following argument: - 1. Recently Added: `pythonw -c movies -PlexUser {username}pythonw -c episodes -PlexUser {username}pythonw -c episodes -PlexUser {username}pythonw -c episodes -PlexUser {username}` + 1. Recently Added: `pythonw -c movies -PlexUser %OWNER%pythonw -c episodes -PlexUser %OWNER%pythonw -c episodes -PlexUser %OWNER%pythonw -c episodes -PlexUser %OWNER%` ## Usage @@ -108,7 +110,7 @@ If you want to collect your movies and TV shows, you need to do the Plex Media S ------------------ Trakt Collection ------------------ -c Media type (movies, episodes) --PlexUser The Plex username +-PlexUser The Plex username (check 'Syncing behavior' in section 'More info' in the README.md file) ``` ## More info @@ -120,6 +122,14 @@ Default scrobbler behavior is for: - If your Plex Media Server is connected, we get the ratingkey from the data sent by Tautulli. We make a database filled with ratingkey paired to ids. We search for the ids linked to the ratingkey in the database. We send the ids to Trakt. Trakt.tv uses TMDB database, so sending basic info like season and episode number can mismatch with your plex configuration. This way we ensure that the episode is scrobbled to the correct one on the Trakt end. - If you are not connected to your Plex Media Server, we send the data from Tautulli directly to Trakt. +Syncing behavior: + +- Based on the -c argument, we either sync movies or episodes. It is syncing your entire collection, not just the recently added, so it might take some time. If it takes way too much time, open an issue and I might add/find a way to only sync the recently added content. +- Based on the -PlexUser argument: + - (default behavior) `%OWNER%`, we sync the collections to the owner Trakt account. + - If a user is specified, we sync the collections to the specified user Trakt account. ⚠️ **It will sync like if it was the owner, so even if the user has not access to the library where the content was added.** You can also use a list: `"[username1, username2]"`, typo is very important. + - If `%ALL%` is specified, we sync the collections to all the users Trakt account. It will check if the users have access to the content before adding it to their collection. + ## Similar Projects Inspired from: https://github.com/frugglehost/TautulliBatch2Trakt diff --git a/TautulliPython2Trakt.py b/TautulliPython2Trakt.py index 9b6368d..969129e 100644 --- a/TautulliPython2Trakt.py +++ b/TautulliPython2Trakt.py @@ -8,7 +8,7 @@ from dotenv import load_dotenv, set_key from plexapi.myplex import MyPlexAccount -from utilities import ProgressBar, log, get_from_env +from utilities import ProgressBar, get_from_env, log args = sys.argv[1:] @@ -44,7 +44,7 @@ ------------------ Trakt Collection ------------------ -c Media type (movies, episodes) --PlexUser The Plex username +-PlexUser The Plex username (check 'Syncing behavior' in section 'More info' in the README.md file) #-A Collection action (add, remove) """ @@ -325,7 +325,6 @@ def less_month_expiration_token_users(data): NUMBER_OF_ARGS = len(args) -arguments_string = "" arguments_list = [] if args[0] == "-m": SCROBBLE = True @@ -340,7 +339,6 @@ def less_month_expiration_token_users(data): next_arg = args[i + 1] if i + 1 < NUMBER_OF_ARGS else "" if " " in next_arg: next_arg = f'"{next_arg}"' - arguments_string += f"{current_arg} {next_arg} " arguments_list += [current_arg, next_arg] i += 2 else: @@ -348,7 +346,7 @@ def less_month_expiration_token_users(data): else: SCROBBLE = False -log(f"ARGUMENTS: {arguments_string}") +log(f"ARGUMENTS: {args}") log(f"SCROBBLE: {SCROBBLE}") if SCROBBLE: diff --git a/scrobble.py b/scrobble.py index a8f2ef7..f8c0098 100644 --- a/scrobble.py +++ b/scrobble.py @@ -1,5 +1,4 @@ import json -import os import sys import time diff --git a/sync_collections.py b/sync_collections.py index ea139c4..4fba3d8 100644 --- a/sync_collections.py +++ b/sync_collections.py @@ -6,9 +6,9 @@ import requests from dotenv import load_dotenv from plexapi.library import MovieSection, ShowSection -from get_ids import update_database -from utilities import get_from_env, get_plex_server, log +from get_ids import update_database +from utilities import get_from_env, get_headers, get_plex_server, log load_dotenv() @@ -16,7 +16,7 @@ NUMBER_OF_ARGS = len(ARGS) if len(ARGS) < 4: - print("Not enough arguments given !") + print("Not enough arguments given!") sys.exit(1) i = 0 @@ -35,11 +35,30 @@ case "-c": TYPE = arg case "-PlexUser": - PLEX_USER = arg + if arg == "%OWNER%": + load_dotenv() + data = get_from_env("DATA") + plex = get_plex_server() + if plex.myPlexAccount().username in list(data.keys()): + PLEX_USER = plex.myPlexAccount().username + else: + print("Owner of the server not found in the env!") + sys.exit(1) + elif arg == "%ALL%": # %ALL% option will sync to all users found in the env + PLEX_USER = list(data.keys()) + # We will later as a privacy measure, check if the user has access to the item before adding it to his collection. + else: + # The else is for when we want to sync the collections of a specific user. If owner trusts a user, he can add a notification agent and write the username in the argument -PlexUser. You can do for example, `py TautulliPython2Trakt.py -c movies -PlexUser "username"`. It will bypass the verification if the user has access to the item before adding it to his collection. + PLEX_USER = arg i += 2 if TYPE not in ["movies", "episodes"]: - raise ValueError("Invalid value !") + raise ValueError("Invalid value!") + +# if we sync we are probably doing so because Tautulli triggered `Recently Added` so we update the database for later scrobbling +# might cause some issues if the user has not setup plex access. It might raise an error stopping the script +thread = threading.Thread(target=update_database) +thread.start() plex = get_plex_server() @@ -51,44 +70,36 @@ section for section in ALL_SECTIONS if isinstance(section, ShowSection) ] -HEADERS = { - "Content-Type": "application/json", - "Authorization": f"Bearer {get_from_env('access_token', PLEX_USER)}", - "trakt-api-version": "2", - "trakt-api-key": get_from_env("client_id", PLEX_USER), -} - -# we keep the sections we want based on the -c argument inputed -SECTIONS = MOVIES_SECTIONS if TYPE == "movies" else SHOWS_SECTIONS - -# if we sync we are probably doing so because Tautulli triggered `Recently Added` so we update the database for later scrobbling -thread = threading.Thread(target=update_database) -thread.start() -contents = [] -# we go through each library and get the media/content and retrieve its ids -# we append the ids to the contents list -for lib in SECTIONS: - search = lib.searchEpisodes() if isinstance(lib, ShowSection) else lib.search() - for content in search: - ids = {} - # Iterate through each object in the "ids" list - for obj in content.guids: - # Update the ids dictionary with the dynamic key-value pairs - ids[f"{obj.id.split('://')[0]}"] = ( - int(obj.id.split("://")[1]) - if f"{obj.id.split('://')[0]}" in ["tmdb", "tvdb"] - else obj.id.split("://")[1] - ) - if ids: - contents.append({"ids": ids}) - -# We post the content list with all the ids, it is either movies or episodes based on TYPE -req = requests.post( - "https://api.trakt.tv/sync/collection", - headers=HEADERS, - data=json.dumps({f"{TYPE}": contents}), -) +def get_ids_for_sections(SECTIONS: list): + contents = [] + # we go through each library and get the media/content and retrieve its ids + # we append the ids to the contents list + for lib in SECTIONS: + search = lib.searchEpisodes() if isinstance(lib, ShowSection) else lib.search() + for content in search: + ids = {} + # Iterate through each object in the "ids" list + for obj in content.guids: + # Update the ids dictionary with the dynamic key-value pairs + ids[f"{obj.id.split('://')[0]}"] = ( + int(obj.id.split("://")[1]) + if f"{obj.id.split('://')[0]}" in ["tmdb", "tvdb"] + else obj.id.split("://")[1] + ) + if ids: + contents.append({"ids": ids}) + return contents + + +def sync_with_trakt(contents: list, HEADERS: dict): + # We post the content list with all the ids, it is either movies or episodes based on TYPE + req = requests.post( + "https://api.trakt.tv/sync/collection", + headers=HEADERS, + data=json.dumps({f"{TYPE}": contents}), + ) + return req def display_req(req): @@ -102,12 +113,92 @@ def display_req(req): print(f"{req.status_code} - {req.text}") -display_req(req) -while req.status_code == 429: - time.sleep(5) - req = requests.post( - "https://api.trakt.tv/sync/collection", - headers=HEADERS, - data=json.dumps({f"{TYPE}": contents}), - ) +def sync(ids, HEADERS): + req = sync_with_trakt(ids, HEADERS) display_req(req) + while req.status_code == 429: + time.sleep(5) + req = sync_with_trakt(ids, HEADERS) + display_req(req) + + +def sync_non_owner(users: list): + plex = get_plex_server() + plex_users = plex.myPlexAccount().users() + + # users is the list(data.keys()) from the data env variable, so we redefine the users variable to only include the users that are in the plex_users list + # this will remove the owner as the owner is not in the plex_users list + users = [ + user for user in plex_users if user.username in users + ] # in users is the list(data.keys()) + log(f"Users: {users}") + for user in users: + # we keep the sections we want based on the -c argument inputed + # we define this here to then after, remove the sections that the user doesn't have access to + sections = MOVIES_SECTIONS if TYPE == "movies" else SHOWS_SECTIONS + + for lib_index in range(len(sections)): + #! Here we assume the owner share only one server with the user, if he shares more than one, this might have unknown behaviour + if user.servers[0].section(sections[lib_index].title).shared is False: + sections.pop( + lib_index + ) # we remove the library from the list if the user doesn't have access to it + HEADERS = get_headers(user.username) + + ids = get_ids_for_sections(sections) + sync(ids, HEADERS) + + +def sync_owner_like(username): + """If username is empty, we sync the owner of the PMS. + If the username is passed, we sync the username that has been passed with same privileges as the owner. It means that even if the username doesn't have access to a library, it will still be synced. For privacy concerns, the `sync_non_owner` function is recommended. + """ + + # we keep the sections we want based on the -c argument inputed + # we define this here to then after, remove the sections that the user doesn't have access to + SECTIONS = MOVIES_SECTIONS if TYPE == "movies" else SHOWS_SECTIONS + + data = json.loads(get_from_env("data")) + if username == plex.myPlexAccount().username and username not in list(data.keys()): + print( + f"Owner of the server {plex.myPlexAccount().username} not found in the env!" + ) + log( + f"Owner of the server {plex.myPlexAccount().username} not found in the env!" + ) + sys.exit(1) + elif username not in list(data.keys()): + print(f"Username {username} not found in the env!") + log(f"Username {username} not found in the env!") + sys.exit(1) + + HEADERS = get_headers(username) + + ids = get_ids_for_sections(SECTIONS) + sync(ids, HEADERS) + + +if arg == "%OWNER%": # we sync the owner + log("Syncing owner...") + sync_owner_like(plex.myPlexAccount().username) +elif ( + arg == "%ALL%" +): # if the argument is %ALL%, we sync all the users and check that they have each access to the libraries + log("Syncing all...") + sync_owner_like(plex.myPlexAccount().username) + sync_non_owner(PLEX_USER) +elif arg != "": # we sync the user or users that have been passed + if arg.startswith("[") and arg.endswith("]"): + arg = arg.strip("][").split(", ") + log(f"Passed users: {arg}") + for user in arg: + log(f"Syncing {user}...") + sync_owner_like(user) + else: + log(f"Syncing {arg}...") + sync_owner_like(PLEX_USER) +else: + print("No argument passed!") + log("No argument passed!") + sys.exit(1) +log("Synced!") diff --git a/utilities.py b/utilities.py index ed0d574..7c1cdb5 100644 --- a/utilities.py +++ b/utilities.py @@ -100,6 +100,15 @@ def get_from_env( return data[user][key] +def get_headers(username): + return { + "Content-Type": "application/json", + "Authorization": f"Bearer {get_from_env('access_token', username)}", + "trakt-api-version": "2", + "trakt-api-key": get_from_env("client_id", username), + } + + class ProgressBar: def __init__(self, title): self.title: str = title