From 2a081a3693717c249f6f81c1c9fc55da0915ef55 Mon Sep 17 00:00:00 2001 From: 0xChloe Date: Sun, 16 Jun 2024 01:42:05 +0000 Subject: [PATCH 01/12] refactoring a bunch of shit --- .dockerignore | 1 + {spotiplex => old}/confighandler.py | 0 {spotiplex => old}/lidarr.py | 0 old/main.py | 200 ++++++++++++ {spotiplex => old}/plex.py | 0 {spotiplex => old}/requirements.txt | 0 {spotiplex => old}/spotify.py | 0 poetry.lock | 321 +++++++++++++++++--- pyproject.toml | 13 +- ruff.toml | 5 + spotiplex/__init__.py | 1 + spotiplex/config.py | 53 ++++ spotiplex/main.py | 212 ++----------- spotiplex/modules/confighandler/__init__.py | 1 + spotiplex/modules/confighandler/main.py | 36 +++ spotiplex/modules/lidarr/__init__.py | 1 + spotiplex/modules/lidarr/main.py | 44 +++ spotiplex/modules/plex/__init__.py | 1 + spotiplex/modules/plex/main.py | 155 ++++++++++ spotiplex/modules/spotify/__init__.py | 1 + spotiplex/modules/spotify/main.py | 52 ++++ spotiplex/modules/spotiplex/__init__.py | 0 spotiplex/modules/spotiplex/main.py | 100 ++++++ 23 files changed, 966 insertions(+), 231 deletions(-) rename {spotiplex => old}/confighandler.py (100%) rename {spotiplex => old}/lidarr.py (100%) create mode 100644 old/main.py rename {spotiplex => old}/plex.py (100%) rename {spotiplex => old}/requirements.txt (100%) rename {spotiplex => old}/spotify.py (100%) create mode 100644 ruff.toml create mode 100644 spotiplex/__init__.py create mode 100644 spotiplex/config.py create mode 100644 spotiplex/modules/confighandler/__init__.py create mode 100644 spotiplex/modules/confighandler/main.py create mode 100644 spotiplex/modules/lidarr/__init__.py create mode 100644 spotiplex/modules/lidarr/main.py create mode 100644 spotiplex/modules/plex/__init__.py create mode 100644 spotiplex/modules/plex/main.py create mode 100644 spotiplex/modules/spotify/__init__.py create mode 100644 spotiplex/modules/spotify/main.py create mode 100644 spotiplex/modules/spotiplex/__init__.py create mode 100644 spotiplex/modules/spotiplex/main.py diff --git a/.dockerignore b/.dockerignore index 4c49bd7..d10bc37 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1 +1,2 @@ .env +.toml \ No newline at end of file diff --git a/spotiplex/confighandler.py b/old/confighandler.py similarity index 100% rename from spotiplex/confighandler.py rename to old/confighandler.py diff --git a/spotiplex/lidarr.py b/old/lidarr.py similarity index 100% rename from spotiplex/lidarr.py rename to old/lidarr.py diff --git a/old/main.py b/old/main.py new file mode 100644 index 0000000..7d2a264 --- /dev/null +++ b/old/main.py @@ -0,0 +1,200 @@ +from plex import PlexService +from spotify import SpotifyService +from lidarr import LidarrAPI as lapi +from confighandler import read_config, write_config +import concurrent.futures +from concurrent.futures import ThreadPoolExecutor +import schedule +import time +import os + + +class Spotiplex: + def __init__(self): + self.config = read_config("spotiplex") + self.first_run = self.config.get("first_run") + + if Spotiplex.is_running_in_docker(): + # Fetching configuration from environment variables if running in Docker + spotiplex_config = { + "lidarr_sync": os.environ.get("SPOTIPLEX_LIDARR_SYNC", "True"), + "plex_users": os.environ.get("USERS", ""), + "worker_count": int(os.environ.get("WORKERS", 1)), + "seconds_interval": int(os.environ.get("INTERVAL", 86400)), + "manual_playlists": os.environ.get( + "SPOTIPLEX_MANUAL_PLAYLISTS", "False" + ), + } + write_config("spotiplex", spotiplex_config) + + spotify_config = { + "client_id": os.environ.get("SPOTIFY_API_ID", ""), + "api_key": os.environ.get("SPOTIFY_API_KEY", ""), + } + write_config("spotify", spotify_config) + + plex_config = { + "url": os.environ.get("PLEX_URL", ""), + "api_key": os.environ.get("PLEX_TOKEN", ""), + "replace": os.environ.get("REPLACE", "False"), + } + write_config("plex", plex_config) + + lidarr_config = { + "url": os.environ.get("LIDARR_IP", ""), + "api_key": os.environ.get("LIDARR_TOKEN", ""), + } + write_config("lidarr", lidarr_config) + + print("Configuration set from environment variables.") + elif self.first_run is None or self.first_run == "True": + Spotiplex.configurator(self) + self.config = read_config("spotiplex") + self.spotify_service = SpotifyService() + self.plex_service = PlexService() + self.lidarr_api = lapi() + + self.lidarr_sync = (self.config.get("lidarr_sync", "false")).lower() + self.plex_users = self.config.get("plex_users") + self.user_list = self.plex_users.split(",") if self.plex_users else [] + self.worker_count = int(self.config.get("worker_count")) + self.replace_existing = self.config.get("replace_existing") + self.seconds_interval = int(self.config.get("seconds_interval")) + if self.lidarr_sync == "true": + self.sync_lists = self.lidarr_api.get_lidarr_playlists() + else: + # This should be an array of arrays to be run by multiple 'threads': + # For example: [["playlist1"],["playlist2"],["playlist3","playlist4"]] + self.sync_lists = self.config.get("manual_playlists") + print(f"Attempting to run for {self.sync_lists}") + self.default_user = self.plex_service.plex.myPlexAccount().username + + # If the the user list provided is empty, add the default user from the token + if not self.user_list or len(self.user_list) == 0: + self.user_list.append(self.default_user) + + def process_for_user(self, user): + print(f"processing for user {user}") + if user == self.default_user: + self.plex_service.plex = self.plex_service.plex + print(f"Processing playlists for user: {user}") + print("User matches credentials provided, defaulting.") + else: + print(f"Attempting to switch to user {user}") + self.plex_service.plex = self.plex_service.plex.switchUser(user) + + with ThreadPoolExecutor(max_workers=self.worker_count) as executor: + futures = [ + executor.submit( + self.process_playlist, + playlist, + self.plex_service, + self.spotify_service, + self.replace_existing, + ) + for playlist in self.sync_lists + ] + + for future in concurrent.futures.as_completed(futures): + try: + future.result() + except Exception as e: + print(f"Thread resulted in an error: {e}") + + def is_running_in_docker(): + return os.path.exists("/.dockerenv") + + def run(self): + for user in self.user_list: + self.process_for_user(user) + if self.seconds_interval > 0: + schedule.every(self.seconds_interval).seconds.do(self.run) + while True: + schedule.run_pending() + time.sleep(1) + + def extract_playlist_id(playlist_url): # parse playlist ID from URL if applicable + if "?si=" in playlist_url: + playlist_url = playlist_url.split("?si=")[0] + + return ( + playlist_url.split("playlist/")[1] + if "playlist/" in playlist_url + else playlist_url + ) + + def process_playlist( + self, playlists, plex_service, spotify_service, replace_existing + ): + for playlist in playlists: + try: + playlist_id = Spotiplex.extract_playlist_id(playlist) + print(playlist_id) + playlist_name = spotify_service.get_playlist_name(playlist_id) + spotify_tracks = spotify_service.get_playlist_tracks(playlist_id) + plex_tracks = plex_service.check_tracks_in_plex(spotify_tracks) + plex_service.create_or_update_playlist( + playlist_name, playlist_id, plex_tracks + ) + print(f"Processed playlist '{playlist_name}'.") + except Exception as e: + print(f"Error processing playlist '{playlist}':", e) + + def configurator(self): + # Config for Spotiplex + + print( + "Welcome to Spotiplex! It seems this is your first run of the application, please enter your configuration variables below. Press Enter to continue..." + ) + spotiplex_config = { + "lidarr_sync": input("Enter Lidarr sync option (True/False): "), + "plex_users": input("Enter comma-separated Plex user names: "), + "worker_count": int( + input( + "Enter the number of worker threads (Not recommened to exceed core count. 5 is usually a good value.): " + ) + ), + "seconds_interval": int( + input( + "Enter the interval in seconds for scheduling, set to 0 if you don't want the script to repeat: " + ) + ), + "manual_playlists": input("Enter manual playlists (True/False): "), + } + + # Config for SpotifyService + spotify_config = { + "client_id": input("Enter Spotify client ID: "), + "api_key": input("Enter Spotify API key: "), + } + write_config("spotify", spotify_config) + + # Config for PlexService + plex_config = { + "url": input("Enter Plex server URL: "), + "api_key": input("Enter Plex API key: "), + "replace": input("Replace existing Plex data? (True/False): "), + } + write_config("plex", plex_config) + + lidarr_config = { + "url": input("Enter Lidarr URL: "), + "api_key": input("Enter Lidarr API Key: "), + } + write_config("lidarr", lidarr_config) + + spotiplex_config["first_run"] = "False" + write_config("spotiplex", spotiplex_config) + + print("Configuration complete!") + + +def main(): + spotiplex = Spotiplex() + spotiplex.run() + + +if __name__ == "__main__": + start_time = time.time() + main() + print("--- %s seconds ---" % (time.time() - start_time)) diff --git a/spotiplex/plex.py b/old/plex.py similarity index 100% rename from spotiplex/plex.py rename to old/plex.py diff --git a/spotiplex/requirements.txt b/old/requirements.txt similarity index 100% rename from spotiplex/requirements.txt rename to old/requirements.txt diff --git a/spotiplex/spotify.py b/old/spotify.py similarity index 100% rename from spotiplex/spotify.py rename to old/spotify.py diff --git a/poetry.lock b/poetry.lock index d9640ef..2660b52 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,27 @@ # This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +[[package]] +name = "anyio" +version = "4.4.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.8" +files = [ + {file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"}, + {file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" +typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} + +[package.extras] +doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +trio = ["trio (>=0.23)"] + [[package]] name = "async-timeout" version = "4.0.3" @@ -13,13 +35,13 @@ files = [ [[package]] name = "certifi" -version = "2023.11.17" +version = "2024.6.2" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"}, - {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, + {file = "certifi-2024.6.2-py3-none-any.whl", hash = "sha256:ddc6c8ce995e6987e7faf5e3f1b02b302836a0e5d98ece18392cb1a36c72ad56"}, + {file = "certifi-2024.6.2.tar.gz", hash = "sha256:3cd43f1c6fa7dedc5899d69d3ad0398fd018ad1a17fba83ddaf78aa46c747516"}, ] [[package]] @@ -121,26 +143,174 @@ files = [ {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, ] +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.1" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"}, + {file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.7" +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + +[[package]] +name = "httpcore" +version = "1.0.5" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"}, + {file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.13,<0.15" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<0.26.0)"] + +[[package]] +name = "httpx" +version = "0.27.0" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"}, + {file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" +sniffio = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] + [[package]] name = "idna" -version = "3.6" +version = "3.7" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.5" files = [ - {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, - {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, + {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, + {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, +] + +[[package]] +name = "loguru" +version = "0.7.2" +description = "Python logging made (stupidly) simple" +optional = false +python-versions = ">=3.5" +files = [ + {file = "loguru-0.7.2-py3-none-any.whl", hash = "sha256:003d71e3d3ed35f0f8984898359d65b79e5b21943f78af86aa5491210429b8eb"}, + {file = "loguru-0.7.2.tar.gz", hash = "sha256:e671a53522515f34fd406340ee968cb9ecafbc4b36c679da03c18fd8d0bd51ac"}, +] + +[package.dependencies] +colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""} +win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} + +[package.extras] +dev = ["Sphinx (==7.2.5)", "colorama (==0.4.5)", "colorama (==0.4.6)", "exceptiongroup (==1.1.3)", "freezegun (==1.1.0)", "freezegun (==1.2.2)", "mypy (==v0.910)", "mypy (==v0.971)", "mypy (==v1.4.1)", "mypy (==v1.5.1)", "pre-commit (==3.4.0)", "pytest (==6.1.2)", "pytest (==7.4.0)", "pytest-cov (==2.12.1)", "pytest-cov (==4.1.0)", "pytest-mypy-plugins (==1.9.3)", "pytest-mypy-plugins (==3.0.0)", "sphinx-autobuild (==2021.3.14)", "sphinx-rtd-theme (==1.3.0)", "tox (==3.27.1)", "tox (==4.11.0)"] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.8" +files = [ + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, ] [[package]] name = "plexapi" -version = "4.15.7" +version = "4.15.13" description = "Python bindings for the Plex API." optional = false python-versions = ">=3.8" files = [ - {file = "PlexAPI-4.15.7-py3-none-any.whl", hash = "sha256:7be975487a2c40617af382e4763ab020842c56ea31ebdb9e09625ded8a6f50b2"}, - {file = "PlexAPI-4.15.7.tar.gz", hash = "sha256:0d3d633e930e15a8d455313864485a7d4e3e25f7205d41805309582fad9a56e9"}, + {file = "PlexAPI-4.15.13-py3-none-any.whl", hash = "sha256:4450cef488dc562a778e84226dd6ffdcb21ce23f62e5234357a9c56f076c4892"}, + {file = "PlexAPI-4.15.13.tar.gz", hash = "sha256:81734409cd574581ae21fb3702b8bd14ef8d2f5cc30c2127cebc8250ad906d14"}, ] [package.dependencies] @@ -149,19 +319,33 @@ requests = "*" [package.extras] alert = ["websocket-client (>=1.3.3)"] +[[package]] +name = "pygments" +version = "2.18.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, + {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + [[package]] name = "redis" -version = "5.0.1" +version = "5.0.6" description = "Python client for Redis database and key-value store" optional = false python-versions = ">=3.7" files = [ - {file = "redis-5.0.1-py3-none-any.whl", hash = "sha256:ed4802971884ae19d640775ba3b03aa2e7bd5e8fb8dfaed2decce4d0fc48391f"}, - {file = "redis-5.0.1.tar.gz", hash = "sha256:0dab495cd5753069d3bc650a0dde8a8f9edde16fc5691b689a566eda58100d0f"}, + {file = "redis-5.0.6-py3-none-any.whl", hash = "sha256:c0d6d990850c627bbf7be01c5c4cbaadf67b48593e913bb71c9819c30df37eee"}, + {file = "redis-5.0.6.tar.gz", hash = "sha256:38473cd7c6389ad3e44a91f4c3eaf6bcb8a9f746007f29bf4fb20824ff0b2197"}, ] [package.dependencies] -async-timeout = {version = ">=4.0.2", markers = "python_full_version <= \"3.11.2\""} +async-timeout = {version = ">=4.0.3", markers = "python_full_version < \"3.11.3\""} [package.extras] hiredis = ["hiredis (>=1.0.0)"] @@ -169,13 +353,13 @@ ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)" [[package]] name = "requests" -version = "2.31.0" +version = "2.32.3" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, - {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, ] [package.dependencies] @@ -188,6 +372,24 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "rich" +version = "13.7.1" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"}, + {file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + [[package]] name = "rtoml" version = "0.10.0" @@ -259,66 +461,107 @@ files = [ ] [[package]] -name = "schedule" -version = "1.2.1" -description = "Job scheduling for humans." +name = "shellingham" +version = "1.5.4" +description = "Tool to Detect Surrounding Shell" optional = false python-versions = ">=3.7" files = [ - {file = "schedule-1.2.1-py2.py3-none-any.whl", hash = "sha256:14cdeb083a596aa1de6dc77639a1b2ac8bf6eaafa82b1c9279d3612823063d01"}, - {file = "schedule-1.2.1.tar.gz", hash = "sha256:843bc0538b99c93f02b8b50e3e39886c06f2d003b24f48e1aa4cadfa3f341279"}, + {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, + {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, ] [[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +python-versions = ">=3.7" files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, ] [[package]] name = "spotipy" -version = "2.23.0" +version = "2.24.0" description = "A light weight Python library for the Spotify Web API" optional = false -python-versions = "*" +python-versions = ">3.8" files = [ - {file = "spotipy-2.23.0-py2-none-any.whl", hash = "sha256:da850fbf62faaa05912132d2886c293a5fbbe8350d0821e7208a6a2fdd6a0079"}, - {file = "spotipy-2.23.0-py3-none-any.whl", hash = "sha256:6bf8b963c10d0a3e51037e4baf92e29732dee36b2a1f1b7dcc8cd5771e662a5b"}, - {file = "spotipy-2.23.0.tar.gz", hash = "sha256:0dfafe08239daae6c16faa68f60b5775d40c4110725e1a7c545ad4c7fb66d4e8"}, + {file = "spotipy-2.24.0-py3-none-any.whl", hash = "sha256:c5aa7338c624a05a8a80dcf9c6761ded3d6bc2bc5df5f22d9398a895b11bd2ae"}, + {file = "spotipy-2.24.0.tar.gz", hash = "sha256:396af81e642086551af157270cdfe742c1739405871ba9dac1fa651b8649ef0d"}, ] [package.dependencies] redis = ">=3.5.3" requests = ">=2.25.0" -six = ">=1.15.0" urllib3 = ">=1.26.0" [package.extras] -doc = ["Sphinx (>=1.5.2)"] +memcache = ["pymemcache (>=3.5.2)"] test = ["mock (==2.0.0)"] +[[package]] +name = "typer" +version = "0.12.3" +description = "Typer, build great CLIs. Easy to code. Based on Python type hints." +optional = false +python-versions = ">=3.7" +files = [ + {file = "typer-0.12.3-py3-none-any.whl", hash = "sha256:070d7ca53f785acbccba8e7d28b08dcd88f79f1fbda035ade0aecec71ca5c914"}, + {file = "typer-0.12.3.tar.gz", hash = "sha256:49e73131481d804288ef62598d97a1ceef3058905aa536a1134f90891ba35482"}, +] + +[package.dependencies] +click = ">=8.0.0" +rich = ">=10.11.0" +shellingham = ">=1.3.0" +typing-extensions = ">=3.7.4.3" + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + [[package]] name = "urllib3" -version = "2.1.0" +version = "2.2.1" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.8" files = [ - {file = "urllib3-2.1.0-py3-none-any.whl", hash = "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3"}, - {file = "urllib3-2.1.0.tar.gz", hash = "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54"}, + {file = "urllib3-2.2.1-py3-none-any.whl", hash = "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d"}, + {file = "urllib3-2.2.1.tar.gz", hash = "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19"}, ] [package.extras] brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "win32-setctime" +version = "1.1.0" +description = "A small Python utility to set file creation time on Windows" +optional = false +python-versions = ">=3.5" +files = [ + {file = "win32_setctime-1.1.0-py3-none-any.whl", hash = "sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad"}, + {file = "win32_setctime-1.1.0.tar.gz", hash = "sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2"}, +] + +[package.extras] +dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"] + [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "1a9c4fcf390f721a9a1571c44fae50ec7b555a161f9ae516e820080a74fb1cbd" +content-hash = "68c058878f12b22e6f10cd01c3297f11f1141ac0723b699d6e99fc325a0964e9" diff --git a/pyproject.toml b/pyproject.toml index 44f9c5d..83ebd16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,16 +3,21 @@ name = "spotiplex" version = "0.1.0" description = "" authors = ["0xChloe "] +license = "GPLv3" readme = "README.md" [tool.poetry.dependencies] python = "^3.10" -spotipy = "^2.23.0" -PlexAPI = "^4.15.7" -requests = "^2.31.0" +httpx = "^0.27.0" +PlexAPI = "^4.15.13" +spotipy = "^2.24.0" rtoml = "^0.10.0" -schedule = "^1.2.1" +typer = "^0.12.3" +rich = "^13.7.1" +loguru = "^0.7.2" +[tool.poetry.scripts] +spotiplex = "spotiplex.main:app" [build-system] requires = ["poetry-core"] diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..948c67b --- /dev/null +++ b/ruff.toml @@ -0,0 +1,5 @@ +select = ["ALL"] +target-version = "py310" + +[pydocstyle] +convention = "google" diff --git a/spotiplex/__init__.py b/spotiplex/__init__.py new file mode 100644 index 0000000..1f46580 --- /dev/null +++ b/spotiplex/__init__.py @@ -0,0 +1 @@ +"""Init for spotiplex.""" diff --git a/spotiplex/config.py b/spotiplex/config.py new file mode 100644 index 0000000..fcbaa5c --- /dev/null +++ b/spotiplex/config.py @@ -0,0 +1,53 @@ +import os + +from spotiplex.modules.confighandler.main import read_config + +""" +We're gonna ignore the error about Constant Redefined. +Might be improper but the constants are NOT being redefined imo. +See this for reference: https://github.com/microsoft/pyright/issues/5265 +""" + + +class Config: + """Generic Config class to pull environment vars.""" + + if os.environ.get("DOCKER"): + SPOTIFY_API_KEY = os.environ.get("SPOTIFY_API_KEY") + SPOTIFY_API_ID = os.environ.get("SPOTIFY_API_ID") + + PLEX_API_KEY = os.environ.get("PLEX_API_KEY") + PLEX_SERVER_URL = os.environ.get("PLEX_SERVER_URL") + PLEX_REPLACE = os.environ.get("REPLACE") + + LIDARR_API_KEY = os.environ.get("LIDARR_API_KEY") + LIDARR_API_URL = os.environ.get("LIDARR_API_URL") + + PLEX_USERS = os.environ.get("PLEX_USERS") + WORKER_COUNT: int = int(os.environ.get("WORKER_COUNT", 10)) + SECONDS_INTERVAL = int(os.environ.get("SECONDS_INTERVAL", 60)) + MANUAL_PLAYLISTS: str = os.environ.get("MANUAL_PLAYLISTS", "None") + LIDARR_SYNC = os.environ.get("LIDARR_SYNC", "false") + FIRST_RUN = os.environ.get("FIRST_RUN", "False") + else: + spotify_config = read_config("spotify") + plex_config = read_config("plex") + lidarr_config = read_config("lidarr") + spotiplex_config = read_config("spotiplex") + + SPOTIFY_API_KEY = spotify_config.get("api_key") + SPOTIFY_API_ID = spotify_config.get("client_id") + + PLEX_API_KEY = plex_config.get("api_key") + PLEX_SERVER_URL = plex_config.get("url") + PLEX_REPLACE = plex_config.get("replace") + + LIDARR_API_KEY = lidarr_config.get("api_key") + LIDARR_API_URL = lidarr_config.get("url") + + PLEX_USERS = spotiplex_config.get("plex_users") + WORKER_COUNT: int = int(spotiplex_config.get("worker_count", 10)) + SECONDS_INTERVAL = int(spotiplex_config.get("seconds_interval", 60)) + MANUAL_PLAYLISTS: str = spotiplex_config.get("manual_playlists", "None") + LIDARR_SYNC = spotiplex_config.get("lidarr_sync", "false") + FIRST_RUN = spotiplex_config.get("first_run", "False") diff --git a/spotiplex/main.py b/spotiplex/main.py index 7d2a264..39dd040 100644 --- a/spotiplex/main.py +++ b/spotiplex/main.py @@ -1,200 +1,36 @@ -from plex import PlexService -from spotify import SpotifyService -from lidarr import LidarrAPI as lapi -from confighandler import read_config, write_config -import concurrent.futures -from concurrent.futures import ThreadPoolExecutor -import schedule -import time -import os +"""Init for Typer app/main functions.""" +import typer +from loguru import logger -class Spotiplex: - def __init__(self): - self.config = read_config("spotiplex") - self.first_run = self.config.get("first_run") +import spotiplex.modules.spotiplex.main as sp_module - if Spotiplex.is_running_in_docker(): - # Fetching configuration from environment variables if running in Docker - spotiplex_config = { - "lidarr_sync": os.environ.get("SPOTIPLEX_LIDARR_SYNC", "True"), - "plex_users": os.environ.get("USERS", ""), - "worker_count": int(os.environ.get("WORKERS", 1)), - "seconds_interval": int(os.environ.get("INTERVAL", 86400)), - "manual_playlists": os.environ.get( - "SPOTIPLEX_MANUAL_PLAYLISTS", "False" - ), - } - write_config("spotiplex", spotiplex_config) +app = typer.Typer() - spotify_config = { - "client_id": os.environ.get("SPOTIFY_API_ID", ""), - "api_key": os.environ.get("SPOTIFY_API_KEY", ""), - } - write_config("spotify", spotify_config) +# Configure logger to write logs to a file +logger.add("spotiplex.log", rotation="1 MB") - plex_config = { - "url": os.environ.get("PLEX_URL", ""), - "api_key": os.environ.get("PLEX_TOKEN", ""), - "replace": os.environ.get("REPLACE", "False"), - } - write_config("plex", plex_config) - lidarr_config = { - "url": os.environ.get("LIDARR_IP", ""), - "api_key": os.environ.get("LIDARR_TOKEN", ""), - } - write_config("lidarr", lidarr_config) +@app.command() +def sync_lidarr_imports() -> None: + """Syncs all playlists currently being pulled via Lidarr.""" + sp_instance = sp_module.Spotiplex(lidarr=True, playlist_id=None) + sp_instance.run() - print("Configuration set from environment variables.") - elif self.first_run is None or self.first_run == "True": - Spotiplex.configurator(self) - self.config = read_config("spotiplex") - self.spotify_service = SpotifyService() - self.plex_service = PlexService() - self.lidarr_api = lapi() - self.lidarr_sync = (self.config.get("lidarr_sync", "false")).lower() - self.plex_users = self.config.get("plex_users") - self.user_list = self.plex_users.split(",") if self.plex_users else [] - self.worker_count = int(self.config.get("worker_count")) - self.replace_existing = self.config.get("replace_existing") - self.seconds_interval = int(self.config.get("seconds_interval")) - if self.lidarr_sync == "true": - self.sync_lists = self.lidarr_api.get_lidarr_playlists() - else: - # This should be an array of arrays to be run by multiple 'threads': - # For example: [["playlist1"],["playlist2"],["playlist3","playlist4"]] - self.sync_lists = self.config.get("manual_playlists") - print(f"Attempting to run for {self.sync_lists}") - self.default_user = self.plex_service.plex.myPlexAccount().username +@app.command() +def sync_manual_lists() -> None: + """Syncs all playlists specified in config file.""" + sp_instance = sp_module.Spotiplex(lidarr=False, playlist_id=None) + sp_instance.run() - # If the the user list provided is empty, add the default user from the token - if not self.user_list or len(self.user_list) == 0: - self.user_list.append(self.default_user) - - def process_for_user(self, user): - print(f"processing for user {user}") - if user == self.default_user: - self.plex_service.plex = self.plex_service.plex - print(f"Processing playlists for user: {user}") - print("User matches credentials provided, defaulting.") - else: - print(f"Attempting to switch to user {user}") - self.plex_service.plex = self.plex_service.plex.switchUser(user) - - with ThreadPoolExecutor(max_workers=self.worker_count) as executor: - futures = [ - executor.submit( - self.process_playlist, - playlist, - self.plex_service, - self.spotify_service, - self.replace_existing, - ) - for playlist in self.sync_lists - ] - - for future in concurrent.futures.as_completed(futures): - try: - future.result() - except Exception as e: - print(f"Thread resulted in an error: {e}") - - def is_running_in_docker(): - return os.path.exists("/.dockerenv") - - def run(self): - for user in self.user_list: - self.process_for_user(user) - if self.seconds_interval > 0: - schedule.every(self.seconds_interval).seconds.do(self.run) - while True: - schedule.run_pending() - time.sleep(1) - - def extract_playlist_id(playlist_url): # parse playlist ID from URL if applicable - if "?si=" in playlist_url: - playlist_url = playlist_url.split("?si=")[0] - - return ( - playlist_url.split("playlist/")[1] - if "playlist/" in playlist_url - else playlist_url - ) - - def process_playlist( - self, playlists, plex_service, spotify_service, replace_existing - ): - for playlist in playlists: - try: - playlist_id = Spotiplex.extract_playlist_id(playlist) - print(playlist_id) - playlist_name = spotify_service.get_playlist_name(playlist_id) - spotify_tracks = spotify_service.get_playlist_tracks(playlist_id) - plex_tracks = plex_service.check_tracks_in_plex(spotify_tracks) - plex_service.create_or_update_playlist( - playlist_name, playlist_id, plex_tracks - ) - print(f"Processed playlist '{playlist_name}'.") - except Exception as e: - print(f"Error processing playlist '{playlist}':", e) - - def configurator(self): - # Config for Spotiplex - - print( - "Welcome to Spotiplex! It seems this is your first run of the application, please enter your configuration variables below. Press Enter to continue..." - ) - spotiplex_config = { - "lidarr_sync": input("Enter Lidarr sync option (True/False): "), - "plex_users": input("Enter comma-separated Plex user names: "), - "worker_count": int( - input( - "Enter the number of worker threads (Not recommened to exceed core count. 5 is usually a good value.): " - ) - ), - "seconds_interval": int( - input( - "Enter the interval in seconds for scheduling, set to 0 if you don't want the script to repeat: " - ) - ), - "manual_playlists": input("Enter manual playlists (True/False): "), - } - - # Config for SpotifyService - spotify_config = { - "client_id": input("Enter Spotify client ID: "), - "api_key": input("Enter Spotify API key: "), - } - write_config("spotify", spotify_config) - - # Config for PlexService - plex_config = { - "url": input("Enter Plex server URL: "), - "api_key": input("Enter Plex API key: "), - "replace": input("Replace existing Plex data? (True/False): "), - } - write_config("plex", plex_config) - - lidarr_config = { - "url": input("Enter Lidarr URL: "), - "api_key": input("Enter Lidarr API Key: "), - } - write_config("lidarr", lidarr_config) - - spotiplex_config["first_run"] = "False" - write_config("spotiplex", spotiplex_config) - - print("Configuration complete!") - - -def main(): - spotiplex = Spotiplex() - spotiplex.run() +# Uncomment and complete this function if needed in the future +# @app.command() +# def sync_single_list(playlist_id: str) -> None: +# """Takes a playlist URL or ID and manually syncs.""" +# sp_instance = sp_module.Spotiplex() +# sp_instance.sync_single_playlist(playlist_id) if __name__ == "__main__": - start_time = time.time() - main() - print("--- %s seconds ---" % (time.time() - start_time)) + app() diff --git a/spotiplex/modules/confighandler/__init__.py b/spotiplex/modules/confighandler/__init__.py new file mode 100644 index 0000000..bf90304 --- /dev/null +++ b/spotiplex/modules/confighandler/__init__.py @@ -0,0 +1 @@ +"""Module for interacting with toml config file.""" diff --git a/spotiplex/modules/confighandler/main.py b/spotiplex/modules/confighandler/main.py new file mode 100644 index 0000000..1bee74c --- /dev/null +++ b/spotiplex/modules/confighandler/main.py @@ -0,0 +1,36 @@ +import os +from pathlib import Path + +import rtoml +from loguru import logger + +config_file = Path("config.toml") + + +def ensure_config_exists() -> None: + """Ensure the configuration file exists, and create it with default values if it doesn't.""" + if not Path.exists(config_file): + logger.warning("config file missing!") + logger.debug("Current Working Directory:", Path.cwd()) + with Path.open(config_file) as file: + rtoml.dump({}, file) + + +def read_config(service: str) -> dict[str, str]: + """Read config for a given service.""" + logger.debug("Reading config...") + ensure_config_exists() # Check if config file exists + with Path.open(config_file) as file: + config = rtoml.load(file) + return config.get(service, {}) + + +def write_config(service: str, data): + """Write config for a given service (not in use).""" + print("writing config") + ensure_config_exists() # Check if config file exists + with open(config_file) as file: + config = rtoml.load(file) + config[service] = data + with open(config_file, "w") as file: + rtoml.dump(config, file) diff --git a/spotiplex/modules/lidarr/__init__.py b/spotiplex/modules/lidarr/__init__.py new file mode 100644 index 0000000..21432f5 --- /dev/null +++ b/spotiplex/modules/lidarr/__init__.py @@ -0,0 +1 @@ +"""Module for interacting with Lidarr API.""" diff --git a/spotiplex/modules/lidarr/main.py b/spotiplex/modules/lidarr/main.py new file mode 100644 index 0000000..b850fc7 --- /dev/null +++ b/spotiplex/modules/lidarr/main.py @@ -0,0 +1,44 @@ +import httpx +from spotiplex.config import Config +from loguru import logger + + +class LidarrClass: + """Class to contain Lidarr functions.""" + + def __init__(self: "LidarrClass") -> None: + """Class init for LidarrClass""" + self.url = Config.LIDARR_API_URL + self.api_key = Config.LIDARR_API_KEY + self.headers = {"X-Api-Key": self.api_key} + + def lidarr_request( + self: "LidarrClass", + endpoint_path: str, + ) -> httpx.Response | None: + """Generic request function.""" + try: + response = httpx.get(url=f"{self.url}{endpoint_path}", headers=self.headers) + response.raise_for_status() + return response.json() + except httpx.RequestError as e: + logger.debug(f"Error during request: {e}") + return None + + def playlist_request(self: "LidarrClass") -> list | None: + """Request and process playlists from Lidarr.""" + endpoint = "/api/v1/importlist" + raw_playlists = self.lidarr_request(endpoint_path=endpoint) + + if raw_playlists: + return [ + field.get("value", []) + for entry in raw_playlists + if entry.get("listType") == "spotify" + for field in entry.get("fields", []) + if field.get("name") == "playlistIds" + ] + + else: + logger.debug("No playlists found!") + return None diff --git a/spotiplex/modules/plex/__init__.py b/spotiplex/modules/plex/__init__.py new file mode 100644 index 0000000..081313d --- /dev/null +++ b/spotiplex/modules/plex/__init__.py @@ -0,0 +1 @@ +"""Module for interacting with plex using the PlexAPI package.""" diff --git a/spotiplex/modules/plex/main.py b/spotiplex/modules/plex/main.py new file mode 100644 index 0000000..7c32a52 --- /dev/null +++ b/spotiplex/modules/plex/main.py @@ -0,0 +1,155 @@ +import datetime + +import httpx +from loguru import logger +from plexapi.exceptions import BadRequest, NotFound +from plexapi.playlist import Playlist # Typing +from plexapi.server import PlexServer + +from spotiplex.config import Config + + +class PlexClass: + """Class to contain Plex functions.""" + + def __init__(self: "PlexClass") -> None: + """Init for Plex class to set up variables and initiate connection.""" + self.plex_url = Config.PLEX_SERVER_URL + self.plex_key = Config.PLEX_API_KEY + self.replacement_policy = Config.PLEX_REPLACE + self.plex = self.connect_plex() + + def connect_plex(self: "PlexClass") -> PlexServer: + """Simple function to initiate Plex Server connection.""" + session = httpx.Client(verify=False) # noqa: S501 Risk is acceptabl for me, feel free to require HTTPS, not a requirement of this app + return PlexServer(self.plex_url, self.plex_key, session=session) + + def match_spotify_tracks_in_plex( + self: "PlexClass", + spotify_tracks: list[tuple[str, str]], + ) -> list: + """Match Spotify tracks in Plex library and provide a summary of the import.""" + logger.debug("Checking tracks in plex...") + matched_tracks = [] + missing_tracks = [] + total_tracks = len(spotify_tracks) + music_library = self.plex.library.section("Music") + + for track_name, artist_name in spotify_tracks: + artist_tracks_in_plex = music_library.search(title=artist_name) + if not artist_tracks_in_plex: + logger.debug(f"No results found for artist: {artist_name}") + missing_tracks.append((track_name, artist_name)) + continue + + try: + plex_track = next( + ( + track.track(title=track_name) + for track in artist_tracks_in_plex + if track.track(title=track_name) + ), + None, + ) + except NotFound: + logger.debug( + f"Track '{track_name}' by '{artist_name}' not found in Plex.", + ) + plex_track = None + except (Exception, BadRequest) as plex_search_exception: + logger.debug( + f"Exception trying to search for artist '{artist_name}', track '{track_name}': {plex_search_exception}", + ) + plex_track = None + + if plex_track: + matched_tracks.append(plex_track) + else: + logger.debug("Song not in Plex!") + logger.debug( + f"Found artists for '{artist_name}' ({len(artist_tracks_in_plex)})", + ) + logger.debug(f"Attempted to match song '{track_name}', but could not!") + missing_tracks.append((track_name, artist_name)) + + success_percentage = ( + (len(matched_tracks) / total_tracks) * 100 if total_tracks else 0 + ) + logger.debug( + f"We successfully found {len(matched_tracks)}/{len(spotify_tracks)} or {success_percentage:.2f}% of the tracks.", + ) + logger.debug(f"We are missing these tracks: {missing_tracks}") + return matched_tracks + + def create_playlist( + self: "PlexClass", + playlist_name: str, + playlist_id: str, + tracks: list, + ) -> Playlist | None: + """Create a playlist in Plex with the given tracks.""" + now = datetime.datetime.now() + try: + iteration_tracks = tracks[:300] + del tracks[:300] # Delete should be lower impact than sliciing + + new_playlist: Playlist = self.plex.createPlaylist( + playlist_name, + items=iteration_tracks, + ) + new_playlist.editSummary( + summary=f"Playlist autocreated with Spotiplex on {now}. Source is Spotify:{playlist_id}", + ) + + while tracks: + iteration_tracks = tracks[:300] + del tracks[:300] # Delete should be lower impact than sliciing + new_playlist.addItems(iteration_tracks) + + except Exception as e: + logger.debug(f"Error creating playlist {playlist_name}: {e}") + return None + else: + return new_playlist + + def update_playlist( + self: "PlexClass", + existing_playlist: Playlist, + playlist_id: str, + tracks: list, + ) -> object: + """Update an existing playlist in Plex.""" + now = datetime.datetime.now() + if self.replacement_policy: + existing_playlist.delete() + return self.create_playlist( + existing_playlist.title, + playlist_id, + tracks, + ) + else: + existing_playlist.editSummary( + summary=f"Playlist updated by Spotiplex on {now}. Source is Spotify:{playlist_id}", + ) + existing_playlist.addItems(tracks) + return existing_playlist + + def find_playlist_by_name(self, playlist_name: str) -> object | None: + """Find a playlist by name in Plex.""" + playlists = self.plex.playlists() + for playlist in playlists: + if playlist.title == playlist_name: + return playlist + return None + + def create_or_update_playlist( + self, + playlist_name: str, + playlist_id: str, + tracks: list, + ) -> object | None: + """Create or update a playlist in Plex.""" + existing_playlist = self.find_playlist_by_name(playlist_name) + if existing_playlist: + return self.update_playlist(existing_playlist, tracks) + return self.create_playlist(playlist_name, playlist_id, tracks) diff --git a/spotiplex/modules/spotify/__init__.py b/spotiplex/modules/spotify/__init__.py new file mode 100644 index 0000000..9e3976a --- /dev/null +++ b/spotiplex/modules/spotify/__init__.py @@ -0,0 +1 @@ +"""Module for interacting with Spotify via spotipy package.""" diff --git a/spotiplex/modules/spotify/main.py b/spotiplex/modules/spotify/main.py new file mode 100644 index 0000000..270da1f --- /dev/null +++ b/spotiplex/modules/spotify/main.py @@ -0,0 +1,52 @@ +import spotipy +from loguru import logger +from spotipy import Spotify +from spotipy.oauth2 import SpotifyClientCredentials + +from spotiplex.config import Config + + +class SpotifyClass: + """Class for interacting with Spotify.""" + + def __init__(self: "SpotifyClass") -> None: + """Init for Spotify class.""" + self.spotify_id = Config.SPOTIFY_API_ID + self.spotify_key = Config.SPOTIFY_API_KEY + self.sp = self.connect_spotify() + + def connect_spotify(self: "SpotifyClass") -> Spotify: + """Init of spotify connection.""" + auth_manager = SpotifyClientCredentials( + client_id=self.spotify_id, + client_secret=self.spotify_key, + ) + return spotipy.Spotify(auth_manager=auth_manager) + + def get_playlist_tracks(self, playlist_id: str) -> list[tuple[str, str]]: + """Fetch tracks from a Spotify playlist.""" + tracks: list[tuple[str, str]] = [] + try: + results = self.sp.playlist_tracks(playlist_id) + while results: + tracks.extend( + [ + (item["track"]["name"], item["track"]["artists"][0]["name"]) + for item in results["items"] + ], + ) + results = self.sp.next(results) if results["next"] else None + except Exception as e: + logger.debug(f"Error fetching tracks from Spotify: {e}") + return tracks + + def get_playlist_name(self, playlist_id: str) -> str | None: + """Fetch the name of a Spotify playlist.""" + try: + return self.sp.playlist(playlist_id, fields=["name"])["name"] + except Exception as e: + logger.debug( + f"Error retrieving playlist name from Spotify for playlist {playlist_id}", + ) + logger.debug(f"Error was {e}") + return None diff --git a/spotiplex/modules/spotiplex/__init__.py b/spotiplex/modules/spotiplex/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/spotiplex/modules/spotiplex/main.py b/spotiplex/modules/spotiplex/main.py new file mode 100644 index 0000000..c03b334 --- /dev/null +++ b/spotiplex/modules/spotiplex/main.py @@ -0,0 +1,100 @@ +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime + +from loguru import logger + +from spotiplex.config import ( + Config, +) +from spotiplex.modules.lidarr.main import LidarrClass +from spotiplex.modules.plex.main import PlexClass +from spotiplex.modules.spotify.main import SpotifyClass + + +class Spotiplex: + """Class to hold Spotiplex functions.""" + + def __init__(self, lidarr: bool | None, playlist_id: str | None): + self.spotify_service = SpotifyClass() + self.plex_service = PlexClass() + self.user_list = self.get_user_list() + self.default_user: str = self.plex_service.plex.myPlexAccount().username + print(self.default_user) + self.worker_count: int = Config.WORKER_COUNT + self.replace_existing = Config.PLEX_REPLACE + if not playlist_id: + self.lidarr_service = LidarrClass() + self.lidarr = lidarr + self.get_sync_lists() + + def get_user_list(self) -> list[str]: + """Gets user list and makes it into a usable list.""" + plex_users = Config.PLEX_USERS + user_list: list[str] = plex_users.split(",") if plex_users else [] + if not user_list: + user_list.append(self.default_user) + print(user_list) + return user_list + + def get_sync_lists(self) -> None: + """Runs lidarr function to get lidarr lists or splits manual playlists to list.""" + if self.lidarr: + self.sync_lists = self.lidarr_service.playlist_request() + self.sync_lists = Config.MANUAL_PLAYLISTS.split(",") + + def process_for_user(self, user: str) -> None: + logger.debug(f"Processing for user {user}") + self.plex_service.plex = ( + self.plex_service.plex + if user == self.default_user + else self.plex_service.plex.switchUser(user) + ) + + with ThreadPoolExecutor(max_workers=self.worker_count) as executor: + futures = [ + executor.submit(self.process_playlist, playlist) + for playlist in self.sync_lists + ] + + for future in as_completed(futures): + try: + future.result() + except Exception as e: + logger.debug(f"Thread resulted in an error: {e}") + + def run(self) -> None: + for user in self.user_list: + self.process_for_user(user) + + def process_playlist(self, playlist: str) -> None: + try: + playlist_id = self.extract_playlist_id(playlist) + logger.debug(playlist_id) + playlist_name: str = self.spotify_service.get_playlist_name(playlist_id) + if "Discover Weekly" in playlist_name: + current_date = datetime.now().strftime("%B %d") + playlist_name = f"{playlist_name} {current_date}" + if "Daily Mix" in playlist_name: + current_date = datetime.now().strftime("%B %d") + playlist_name = f"{playlist_name} {current_date}" + spotify_tracks = self.spotify_service.get_playlist_tracks(playlist_id) + plex_tracks = self.plex_service.match_spotify_tracks_in_plex(spotify_tracks) + self.plex_service.create_or_update_playlist( + playlist_name, + playlist_id, + plex_tracks, + ) + logger.debug(f"Processed playlist '{playlist_name}'.") + except Exception as e: + logger.debug(f"Error processing playlist '{playlist}': {e}") + + @staticmethod + def extract_playlist_id(playlist_url: str) -> str: + """Get playlist ID from URL if needed.""" + if "?si=" in playlist_url: + playlist_url = playlist_url.split("?si=")[0] + return ( + playlist_url.split("playlist/")[1] + if "playlist/" in playlist_url + else playlist_url + ) From 542708bd68274e04db45f287ead2b0f30100b84d Mon Sep 17 00:00:00 2001 From: 0xChloe Date: Sun, 16 Jun 2024 18:13:48 +0000 Subject: [PATCH 02/12] Fixing issues with updating playlists --- spotiplex/main.py | 10 ++++---- spotiplex/modules/plex/main.py | 36 +++++++++++++++++------------ spotiplex/modules/spotiplex/main.py | 4 +--- 3 files changed, 28 insertions(+), 22 deletions(-) diff --git a/spotiplex/main.py b/spotiplex/main.py index 39dd040..2101bfa 100644 --- a/spotiplex/main.py +++ b/spotiplex/main.py @@ -2,13 +2,15 @@ import typer from loguru import logger - import spotiplex.modules.spotiplex.main as sp_module -app = typer.Typer() +logger.trace("Initializing logger...") +logger.remove() +logger.add("spotiplex.log", rotation="12:00") -# Configure logger to write logs to a file -logger.add("spotiplex.log", rotation="1 MB") + +logger.trace("Initializing app...") +app = typer.Typer() @app.command() diff --git a/spotiplex/modules/plex/main.py b/spotiplex/modules/plex/main.py index 7c32a52..388add2 100644 --- a/spotiplex/modules/plex/main.py +++ b/spotiplex/modules/plex/main.py @@ -98,7 +98,7 @@ def create_playlist( items=iteration_tracks, ) new_playlist.editSummary( - summary=f"Playlist autocreated with Spotiplex on {now}. Source is Spotify:{playlist_id}", + summary=f"Playlist autocreated with Spotiplex on {now.strftime('%m/%d/%Y')}. Source is Spotify, Playlist ID: {playlist_id}", ) while tracks: @@ -117,10 +117,10 @@ def update_playlist( existing_playlist: Playlist, playlist_id: str, tracks: list, - ) -> object: + ) -> Playlist: """Update an existing playlist in Plex.""" now = datetime.datetime.now() - if self.replacement_policy: + if self.replacement_policy is not False and self.replacement_policy is not None: existing_playlist.delete() return self.create_playlist( existing_playlist.title, @@ -129,27 +129,33 @@ def update_playlist( ) else: existing_playlist.editSummary( - summary=f"Playlist updated by Spotiplex on {now}. Source is Spotify:{playlist_id}", + summary=f"Playlist updated by Spotiplex on {now.strftime('%m/%d/%Y')},. Source is Spotify, Playlist ID: {playlist_id}", ) - existing_playlist.addItems(tracks) + if len(tracks) > 0: + existing_playlist.addItems(tracks) return existing_playlist - def find_playlist_by_name(self, playlist_name: str) -> object | None: + def find_playlist_by_name(self, playlist_name: str) -> Playlist | None: """Find a playlist by name in Plex.""" - playlists = self.plex.playlists() - for playlist in playlists: - if playlist.title == playlist_name: - return playlist - return None + return next( + ( + playlist + for playlist in self.plex.playlists() + if playlist_name in playlist.title + ), + None, + ) def create_or_update_playlist( self, playlist_name: str, playlist_id: str, tracks: list, - ) -> object | None: + ) -> Playlist | None: """Create or update a playlist in Plex.""" existing_playlist = self.find_playlist_by_name(playlist_name) - if existing_playlist: - return self.update_playlist(existing_playlist, tracks) - return self.create_playlist(playlist_name, playlist_id, tracks) + if existing_playlist is not None and tracks: + return self.update_playlist(existing_playlist, playlist_id, tracks) + if tracks: + return self.create_playlist(playlist_name, playlist_id, tracks) + return None diff --git a/spotiplex/modules/spotiplex/main.py b/spotiplex/modules/spotiplex/main.py index c03b334..3b15237 100644 --- a/spotiplex/modules/spotiplex/main.py +++ b/spotiplex/modules/spotiplex/main.py @@ -19,7 +19,6 @@ def __init__(self, lidarr: bool | None, playlist_id: str | None): self.plex_service = PlexClass() self.user_list = self.get_user_list() self.default_user: str = self.plex_service.plex.myPlexAccount().username - print(self.default_user) self.worker_count: int = Config.WORKER_COUNT self.replace_existing = Config.PLEX_REPLACE if not playlist_id: @@ -33,7 +32,7 @@ def get_user_list(self) -> list[str]: user_list: list[str] = plex_users.split(",") if plex_users else [] if not user_list: user_list.append(self.default_user) - print(user_list) + logger.debug(f"Users to process: {user_list}") return user_list def get_sync_lists(self) -> None: @@ -69,7 +68,6 @@ def run(self) -> None: def process_playlist(self, playlist: str) -> None: try: playlist_id = self.extract_playlist_id(playlist) - logger.debug(playlist_id) playlist_name: str = self.spotify_service.get_playlist_name(playlist_id) if "Discover Weekly" in playlist_name: current_date = datetime.now().strftime("%B %d") From a4ebd849aacb879fa3603778deed53bcab4cbc30 Mon Sep 17 00:00:00 2001 From: 0xChloe Date: Sun, 16 Jun 2024 18:21:17 +0000 Subject: [PATCH 03/12] modifying dockerfile, need to change stuff with it Creating shell script to generate crontab for supercronic --- Dockerfile | 30 +++++++++++++++++++++++++++--- generate_crontab.sh | 5 +++++ 2 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 generate_crontab.sh diff --git a/Dockerfile b/Dockerfile index ce3f710..11ba95a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,34 @@ FROM python:latest +# Set environment variables ENV SRC_DIR /usr/bin/spotiplex/ +ENV POETRY_VERSION=1.2.0 +ENV PYTHONUNBUFFERED=1 +ENV CRON_SCHEDULE="0 0 * * *" + +# Install Poetry +RUN pip install "poetry==$POETRY_VERSION" + +# Copy the application source code COPY ./spotiplex ${SRC_DIR}/ +COPY pyproject.toml poetry.lock ${SRC_DIR}/ + +# Set the working directory WORKDIR ${SRC_DIR} -ENV PYTHONUNBUFFERED=1 +# Install dependencies with Poetry +RUN poetry config virtualenvs.create false \ + && poetry install --no-interaction --no-ansi + +# Install supercronic +RUN wget -O /usr/local/bin/supercronic https://github.com/aptible/supercronic/releases/download/v0.1.11/supercronic-linux-amd64 \ + && chmod +x /usr/local/bin/supercronic + +# Copy the script to generate the cron file +COPY generate_cron.sh /usr/local/bin/generate_cron.sh +RUN chmod +x /usr/local/bin/generate_cron.sh + +# Set the command to generate the cron file and run supercronic +CMD ["/bin/sh", "-c", "/usr/local/bin/generate_cron.sh && /usr/local/bin/supercronic /etc/supercronic-cron"] + -RUN pip install -r requirements.txt -CMD ["python", "main.py"] \ No newline at end of file diff --git a/generate_crontab.sh b/generate_crontab.sh new file mode 100644 index 0000000..8d0eca9 --- /dev/null +++ b/generate_crontab.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +# Generate the cron file based on environment variables +echo "${CRON_SCHEDULE} poetry run spotiplex sync-lidarr-imports" > /etc/supercronic-cron +echo "${CRON_SCHEDULE} poetry run spotiplex sync-manual-lists" > /etc/supercronic-cron From 0aff4390dd2dec6db196d8bf35a6b39f3f7d6833 Mon Sep 17 00:00:00 2001 From: 0xChloe Date: Sun, 16 Jun 2024 21:52:26 +0000 Subject: [PATCH 04/12] Adding default config and adding function to dump toml to env for quick switch to docker --- .gitignore | 3 +- default_config.toml | 19 ++++++++++++ spotiplex/main.py | 11 +++++++ spotiplex/modules/confighandler/main.py | 40 ++++++++++++++++++++++++- 4 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 default_config.toml diff --git a/.gitignore b/.gitignore index f3d0a4c..5702583 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ *.log *.pyc *pycache* -config.toml \ No newline at end of file +config.toml +spotiplex.env diff --git a/default_config.toml b/default_config.toml new file mode 100644 index 0000000..b3da2e2 --- /dev/null +++ b/default_config.toml @@ -0,0 +1,19 @@ +[spotify] +client_id = "" +api_key = "" + +[plex] +url = "" +api_key = "" + + +[lidarr] +url = "" +api_key = "" + +[spotiplex] +lidarr_sync = "true" +plex_users = "" #COMMA SEPARATED LIST, NO SPACES! user1,user2 NOT user1, user2 +worker_count = 16 +seconds_interval = 60 #Deprecated, should be replaced with crontab generator +manual_playlists = "" #No spaces, comma separated. Might work with a mix of IDs and URLs but best to pick one format diff --git a/spotiplex/main.py b/spotiplex/main.py index 2101bfa..38d6f49 100644 --- a/spotiplex/main.py +++ b/spotiplex/main.py @@ -1,7 +1,11 @@ """Init for Typer app/main functions.""" +import sys + import typer from loguru import logger + +import spotiplex.modules.confighandler.main as config_handler import spotiplex.modules.spotiplex.main as sp_module logger.trace("Initializing logger...") @@ -20,6 +24,13 @@ def sync_lidarr_imports() -> None: sp_instance.run() +@app.command() +def generate_env() -> None: + """Generate env file from config to use with docker.""" + logger.add(sys.stdout) + config_handler.config_to_env() + + @app.command() def sync_manual_lists() -> None: """Syncs all playlists specified in config file.""" diff --git a/spotiplex/modules/confighandler/main.py b/spotiplex/modules/confighandler/main.py index 1bee74c..09f4290 100644 --- a/spotiplex/modules/confighandler/main.py +++ b/spotiplex/modules/confighandler/main.py @@ -1,10 +1,10 @@ -import os from pathlib import Path import rtoml from loguru import logger config_file = Path("config.toml") +env_file = Path("spotiplex.env") def ensure_config_exists() -> None: @@ -34,3 +34,41 @@ def write_config(service: str, data): config[service] = data with open(config_file, "w") as file: rtoml.dump(config, file) + + +def config_to_env() -> None: + """Convert the config.toml to an env file.""" + logger.debug("Converting config.toml to .env file...") + ensure_config_exists() + with Path.open(config_file) as file: + config = rtoml.load(file) + + with open(env_file, "w") as env: + # Write Spotify config + spotify_config = config.get("spotify", {}) + env.write(f"SPOTIFY_API_KEY={spotify_config.get('api_key', '')}\n") + env.write(f"SPOTIFY_API_ID={spotify_config.get('client_id', '')}\n") + + # Write Plex config + plex_config = config.get("plex", {}) + env.write(f"PLEX_API_KEY={plex_config.get('api_key', '')}\n") + env.write(f"PLEX_SERVER_URL={plex_config.get('url', '')}\n") + env.write(f"PLEX_REPLACE={plex_config.get('replace', '')}\n") + + # Write Lidarr config + lidarr_config = config.get("lidarr", {}) + env.write(f"LIDARR_API_KEY={lidarr_config.get('api_key', '')}\n") + env.write(f"LIDARR_API_URL={lidarr_config.get('url', '')}\n") + + # Write Spotiplex config + spotiplex_config = config.get("spotiplex", {}) + env.write(f"PLEX_USERS={spotiplex_config.get('plex_users', '')}\n") + env.write(f"WORKER_COUNT={spotiplex_config.get('worker_count', 10)}\n") + env.write(f"SECONDS_INTERVAL={spotiplex_config.get('seconds_interval', 60)}\n") + env.write( + f"MANUAL_PLAYLISTS={spotiplex_config.get('manual_playlists', 'None')}\n", + ) + env.write(f"LIDARR_SYNC={spotiplex_config.get('lidarr_sync', 'false')}\n") + env.write(f"FIRST_RUN={spotiplex_config.get('first_run', 'False')}\n") + + logger.debug(".env file created successfully.") From 44a1057086a2883c49186f11dff3a5b71536d4fb Mon Sep 17 00:00:00 2001 From: 0xChloe Date: Sat, 22 Jun 2024 02:46:57 +0000 Subject: [PATCH 05/12] Adding type hinting and fixing some type/return type errors --- Dockerfile | 8 ++++ entrypoint.sh | 5 ++ spotiplex/config.py | 10 ++-- spotiplex/modules/lidarr/main.py | 31 +++++++------ spotiplex/modules/plex/main.py | 42 +++++++++-------- spotiplex/modules/spotify/main.py | 11 +++-- spotiplex/modules/spotiplex/__init__.py | 1 + spotiplex/modules/spotiplex/main.py | 62 ++++++++++++++++--------- 8 files changed, 106 insertions(+), 64 deletions(-) create mode 100644 entrypoint.sh diff --git a/Dockerfile b/Dockerfile index 11ba95a..0962489 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,15 @@ RUN wget -O /usr/local/bin/supercronic https://github.com/aptible/supercronic/re COPY generate_cron.sh /usr/local/bin/generate_cron.sh RUN chmod +x /usr/local/bin/generate_cron.sh +COPY entrypoint.sh ./ +RUN chmod +x entrypoint.sh + + + + # Set the command to generate the cron file and run supercronic CMD ["/bin/sh", "-c", "/usr/local/bin/generate_cron.sh && /usr/local/bin/supercronic /etc/supercronic-cron"] +ENTRYPOINT ["./entrypoint.sh"] + diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..4daf266 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,5 @@ +echo "${CRON_SCHEDULE} poetry run spotiplex sync-lidarr-imports" > /etc/supercronic-cron +echo "${CRON_SCHEDULE} poetry run spotiplex sync-manual-lists" > /etc/supercronic-cron + +poetry run spotiplex sync-manual-lists +poetry run spotiplex sync-lidarr-imports \ No newline at end of file diff --git a/spotiplex/config.py b/spotiplex/config.py index fcbaa5c..f9ddeb9 100644 --- a/spotiplex/config.py +++ b/spotiplex/config.py @@ -1,4 +1,4 @@ -import os +import os # noqa: D100 from spotiplex.modules.confighandler.main import read_config @@ -20,8 +20,8 @@ class Config: PLEX_SERVER_URL = os.environ.get("PLEX_SERVER_URL") PLEX_REPLACE = os.environ.get("REPLACE") - LIDARR_API_KEY = os.environ.get("LIDARR_API_KEY") - LIDARR_API_URL = os.environ.get("LIDARR_API_URL") + LIDARR_API_KEY: str = os.environ.get("LIDARR_API_KEY", "Not Set") + LIDARR_API_URL: str = os.environ.get("LIDARR_API_URL", "Not Set") PLEX_USERS = os.environ.get("PLEX_USERS") WORKER_COUNT: int = int(os.environ.get("WORKER_COUNT", 10)) @@ -42,8 +42,8 @@ class Config: PLEX_SERVER_URL = plex_config.get("url") PLEX_REPLACE = plex_config.get("replace") - LIDARR_API_KEY = lidarr_config.get("api_key") - LIDARR_API_URL = lidarr_config.get("url") + LIDARR_API_KEY: str = lidarr_config.get("api_key", "Not Set") + LIDARR_API_URL: str = lidarr_config.get("url", "Not Set") PLEX_USERS = spotiplex_config.get("plex_users") WORKER_COUNT: int = int(spotiplex_config.get("worker_count", 10)) diff --git a/spotiplex/modules/lidarr/main.py b/spotiplex/modules/lidarr/main.py index b850fc7..1a1a159 100644 --- a/spotiplex/modules/lidarr/main.py +++ b/spotiplex/modules/lidarr/main.py @@ -1,34 +1,38 @@ -import httpx -from spotiplex.config import Config +import httpx # noqa: D100 from loguru import logger +from spotiplex.config import Config + class LidarrClass: """Class to contain Lidarr functions.""" def __init__(self: "LidarrClass") -> None: - """Class init for LidarrClass""" - self.url = Config.LIDARR_API_URL - self.api_key = Config.LIDARR_API_KEY - self.headers = {"X-Api-Key": self.api_key} + """Class init for LidarrClass.""" + self.url: str = Config.LIDARR_API_URL + self.api_key: str = Config.LIDARR_API_KEY + self.headers: dict[str, str] = {"X-Api-Key": self.api_key} def lidarr_request( self: "LidarrClass", endpoint_path: str, - ) -> httpx.Response | None: + ) -> dict | None: """Generic request function.""" try: - response = httpx.get(url=f"{self.url}{endpoint_path}", headers=self.headers) + response: httpx.Response = httpx.get( + url=f"{self.url}{endpoint_path}", + headers=self.headers, + ) response.raise_for_status() return response.json() except httpx.RequestError as e: logger.debug(f"Error during request: {e}") return None - def playlist_request(self: "LidarrClass") -> list | None: + def playlist_request(self: "LidarrClass") -> list[str]: """Request and process playlists from Lidarr.""" - endpoint = "/api/v1/importlist" - raw_playlists = self.lidarr_request(endpoint_path=endpoint) + endpoint: str = "/api/v1/importlist" + raw_playlists: dict | None = self.lidarr_request(endpoint_path=endpoint) if raw_playlists: return [ @@ -39,6 +43,5 @@ def playlist_request(self: "LidarrClass") -> list | None: if field.get("name") == "playlistIds" ] - else: - logger.debug("No playlists found!") - return None + logger.debug("No playlists found!") + return [] diff --git a/spotiplex/modules/plex/main.py b/spotiplex/modules/plex/main.py index 388add2..6c91b70 100644 --- a/spotiplex/modules/plex/main.py +++ b/spotiplex/modules/plex/main.py @@ -1,7 +1,8 @@ -import datetime +import datetime # noqa: D100 import httpx from loguru import logger +from plexapi.audio import Track from plexapi.exceptions import BadRequest, NotFound from plexapi.playlist import Playlist # Typing from plexapi.server import PlexServer @@ -27,10 +28,10 @@ def connect_plex(self: "PlexClass") -> PlexServer: def match_spotify_tracks_in_plex( self: "PlexClass", spotify_tracks: list[tuple[str, str]], - ) -> list: + ) -> list[Track]: """Match Spotify tracks in Plex library and provide a summary of the import.""" logger.debug("Checking tracks in plex...") - matched_tracks = [] + matched_tracks: list[Track] = [] missing_tracks = [] total_tracks = len(spotify_tracks) music_library = self.plex.library.section("Music") @@ -62,9 +63,7 @@ def match_spotify_tracks_in_plex( ) plex_track = None - if plex_track: - matched_tracks.append(plex_track) - else: + if not plex_track: logger.debug("Song not in Plex!") logger.debug( f"Found artists for '{artist_name}' ({len(artist_tracks_in_plex)})", @@ -72,6 +71,9 @@ def match_spotify_tracks_in_plex( logger.debug(f"Attempted to match song '{track_name}', but could not!") missing_tracks.append((track_name, artist_name)) + else: + matched_tracks.append(plex_track) + success_percentage = ( (len(matched_tracks) / total_tracks) * 100 if total_tracks else 0 ) @@ -85,7 +87,7 @@ def create_playlist( self: "PlexClass", playlist_name: str, playlist_id: str, - tracks: list, + tracks: list[Track], ) -> Playlist | None: """Create a playlist in Plex with the given tracks.""" now = datetime.datetime.now() @@ -98,7 +100,10 @@ def create_playlist( items=iteration_tracks, ) new_playlist.editSummary( - summary=f"Playlist autocreated with Spotiplex on {now.strftime('%m/%d/%Y')}. Source is Spotify, Playlist ID: {playlist_id}", + summary=f""" + Playlist autocreated with Spotiplex on {now.strftime('%m/%d/%Y')}. + Source is Spotify, Playlist ID: {playlist_id} + """, ) while tracks: @@ -108,7 +113,7 @@ def create_playlist( except Exception as e: logger.debug(f"Error creating playlist {playlist_name}: {e}") - return None + else: return new_playlist @@ -117,7 +122,7 @@ def update_playlist( existing_playlist: Playlist, playlist_id: str, tracks: list, - ) -> Playlist: + ) -> Playlist | None: """Update an existing playlist in Plex.""" now = datetime.datetime.now() if self.replacement_policy is not False and self.replacement_policy is not None: @@ -127,15 +132,14 @@ def update_playlist( playlist_id, tracks, ) - else: - existing_playlist.editSummary( - summary=f"Playlist updated by Spotiplex on {now.strftime('%m/%d/%Y')},. Source is Spotify, Playlist ID: {playlist_id}", - ) - if len(tracks) > 0: - existing_playlist.addItems(tracks) - return existing_playlist + existing_playlist.editSummary( + summary=f"Playlist updated by Spotiplex on {now.strftime('%m/%d/%Y')},. Source is Spotify, Playlist ID: {playlist_id}", + ) + if len(tracks) > 0: + existing_playlist.addItems(tracks) + return existing_playlist - def find_playlist_by_name(self, playlist_name: str) -> Playlist | None: + def find_playlist_by_name(self: "PlexClass", playlist_name: str) -> Playlist | None: """Find a playlist by name in Plex.""" return next( ( @@ -147,7 +151,7 @@ def find_playlist_by_name(self, playlist_name: str) -> Playlist | None: ) def create_or_update_playlist( - self, + self: "PlexClass", playlist_name: str, playlist_id: str, tracks: list, diff --git a/spotiplex/modules/spotify/main.py b/spotiplex/modules/spotify/main.py index 270da1f..36c7d50 100644 --- a/spotiplex/modules/spotify/main.py +++ b/spotiplex/modules/spotify/main.py @@ -23,7 +23,10 @@ def connect_spotify(self: "SpotifyClass") -> Spotify: ) return spotipy.Spotify(auth_manager=auth_manager) - def get_playlist_tracks(self, playlist_id: str) -> list[tuple[str, str]]: + def get_playlist_tracks( + self: "SpotifyClass", + playlist_id: str, + ) -> list[tuple[str, str]]: """Fetch tracks from a Spotify playlist.""" tracks: list[tuple[str, str]] = [] try: @@ -40,13 +43,15 @@ def get_playlist_tracks(self, playlist_id: str) -> list[tuple[str, str]]: logger.debug(f"Error fetching tracks from Spotify: {e}") return tracks - def get_playlist_name(self, playlist_id: str) -> str | None: + def get_playlist_name(self: "SpotifyClass", playlist_id: str) -> str | None: """Fetch the name of a Spotify playlist.""" try: return self.sp.playlist(playlist_id, fields=["name"])["name"] except Exception as e: logger.debug( - f"Error retrieving playlist name from Spotify for playlist {playlist_id}", + f""" + Error retrieving playlist name from Spotify for playlist {playlist_id} + """, ) logger.debug(f"Error was {e}") return None diff --git a/spotiplex/modules/spotiplex/__init__.py b/spotiplex/modules/spotiplex/__init__.py index e69de29..0c1126d 100644 --- a/spotiplex/modules/spotiplex/__init__.py +++ b/spotiplex/modules/spotiplex/__init__.py @@ -0,0 +1 @@ +"""Module for spotiplex functions.""" diff --git a/spotiplex/modules/spotiplex/main.py b/spotiplex/modules/spotiplex/main.py index 3b15237..4dfda48 100644 --- a/spotiplex/modules/spotiplex/main.py +++ b/spotiplex/modules/spotiplex/main.py @@ -1,4 +1,4 @@ -from concurrent.futures import ThreadPoolExecutor, as_completed +from concurrent.futures import ThreadPoolExecutor, as_completed # noqa: D100 from datetime import datetime from loguru import logger @@ -14,7 +14,12 @@ class Spotiplex: """Class to hold Spotiplex functions.""" - def __init__(self, lidarr: bool | None, playlist_id: str | None): + def __init__( + self: "Spotiplex", + lidarr: bool | None, + playlist_id: str | None, + ) -> None: + """Init for Spotiplex functions.""" self.spotify_service = SpotifyClass() self.plex_service = PlexClass() self.user_list = self.get_user_list() @@ -26,7 +31,7 @@ def __init__(self, lidarr: bool | None, playlist_id: str | None): self.lidarr = lidarr self.get_sync_lists() - def get_user_list(self) -> list[str]: + def get_user_list(self: "Spotiplex") -> list[str]: """Gets user list and makes it into a usable list.""" plex_users = Config.PLEX_USERS user_list: list[str] = plex_users.split(",") if plex_users else [] @@ -35,13 +40,14 @@ def get_user_list(self) -> list[str]: logger.debug(f"Users to process: {user_list}") return user_list - def get_sync_lists(self) -> None: - """Runs lidarr function to get lidarr lists or splits manual playlists to list.""" + def get_sync_lists(self: "Spotiplex") -> None: + """Runs function to get lidarr lists or splits manual playlists to list.""" if self.lidarr: self.sync_lists = self.lidarr_service.playlist_request() - self.sync_lists = Config.MANUAL_PLAYLISTS.split(",") + self.sync_lists: list[str] = Config.MANUAL_PLAYLISTS.split(",") - def process_for_user(self, user: str) -> None: + def process_for_user(self: "Spotiplex", user: str) -> None: + """Syncs playlists for a given user.""" logger.debug(f"Processing for user {user}") self.plex_service.plex = ( self.plex_service.plex @@ -58,31 +64,41 @@ def process_for_user(self, user: str) -> None: for future in as_completed(futures): try: future.result() - except Exception as e: + except Exception as e: # noqa: PERF203 I wonder if there's a better way to do this? idk logger.debug(f"Thread resulted in an error: {e}") - def run(self) -> None: + def run(self: "Spotiplex") -> None: + """Run function to loop through users.""" for user in self.user_list: self.process_for_user(user) - def process_playlist(self, playlist: str) -> None: + def process_playlist(self: "Spotiplex", playlist: str) -> None: + """Process playlists, get data and create plex playlist.""" try: playlist_id = self.extract_playlist_id(playlist) - playlist_name: str = self.spotify_service.get_playlist_name(playlist_id) - if "Discover Weekly" in playlist_name: - current_date = datetime.now().strftime("%B %d") - playlist_name = f"{playlist_name} {current_date}" - if "Daily Mix" in playlist_name: - current_date = datetime.now().strftime("%B %d") - playlist_name = f"{playlist_name} {current_date}" - spotify_tracks = self.spotify_service.get_playlist_tracks(playlist_id) - plex_tracks = self.plex_service.match_spotify_tracks_in_plex(spotify_tracks) - self.plex_service.create_or_update_playlist( - playlist_name, + playlist_name: str | None = self.spotify_service.get_playlist_name( playlist_id, - plex_tracks, ) - logger.debug(f"Processed playlist '{playlist_name}'.") + + if playlist_name: + if "Discover Weekly" in playlist_name or "Daily Mix" in playlist_name: + current_date = datetime.now().strftime("%B %d") + playlist_name = f"{playlist_name} {current_date}" + + spotify_tracks = self.spotify_service.get_playlist_tracks(playlist_id) + plex_tracks = self.plex_service.match_spotify_tracks_in_plex( + spotify_tracks, + ) + self.plex_service.create_or_update_playlist( + playlist_name, + playlist_id, + plex_tracks, + ) + logger.debug(f"Processed playlist '{playlist_name}'.") + else: + logger.debug( + f"Playlist name could not be retrieved for playlist ID '{playlist_id}'.", + ) except Exception as e: logger.debug(f"Error processing playlist '{playlist}': {e}") From 6bb1a03befc0bcc6328d2b14b3dac001523b383e Mon Sep 17 00:00:00 2001 From: 0xChloe Date: Sat, 22 Jun 2024 07:11:44 +0000 Subject: [PATCH 06/12] Dockerfile updates --- Dockerfile | 26 +++++++++----------------- entrypoint.sh | 11 +++++++++-- poetry.lock | 6 +++--- 3 files changed, 21 insertions(+), 22 deletions(-) mode change 100644 => 100755 entrypoint.sh diff --git a/Dockerfile b/Dockerfile index 0962489..bb43883 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,17 +2,17 @@ FROM python:latest # Set environment variables ENV SRC_DIR /usr/bin/spotiplex/ -ENV POETRY_VERSION=1.2.0 +ENV POETRY_VERSION=1.7.1 ENV PYTHONUNBUFFERED=1 -ENV CRON_SCHEDULE="0 0 * * *" - +ENV CRON_SCHEDULE=@hourly +ENV DOCKER=True # Install Poetry RUN pip install "poetry==$POETRY_VERSION" # Copy the application source code -COPY ./spotiplex ${SRC_DIR}/ +COPY ./spotiplex ${SRC_DIR}/spotiplex COPY pyproject.toml poetry.lock ${SRC_DIR}/ - +COPY README.md ${SRC_DIR}/ # Set the working directory WORKDIR ${SRC_DIR} @@ -24,19 +24,11 @@ RUN poetry config virtualenvs.create false \ RUN wget -O /usr/local/bin/supercronic https://github.com/aptible/supercronic/releases/download/v0.1.11/supercronic-linux-amd64 \ && chmod +x /usr/local/bin/supercronic -# Copy the script to generate the cron file -COPY generate_cron.sh /usr/local/bin/generate_cron.sh -RUN chmod +x /usr/local/bin/generate_cron.sh - -COPY entrypoint.sh ./ -RUN chmod +x entrypoint.sh - - - -# Set the command to generate the cron file and run supercronic -CMD ["/bin/sh", "-c", "/usr/local/bin/generate_cron.sh && /usr/local/bin/supercronic /etc/supercronic-cron"] +# Copy entrypoint script +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] -ENTRYPOINT ["./entrypoint.sh"] diff --git a/entrypoint.sh b/entrypoint.sh old mode 100644 new mode 100755 index 4daf266..8404fc6 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,5 +1,12 @@ +#!/usr/bin/env bash + +# Create the crontab file dynamically based on the passed environment variable echo "${CRON_SCHEDULE} poetry run spotiplex sync-lidarr-imports" > /etc/supercronic-cron -echo "${CRON_SCHEDULE} poetry run spotiplex sync-manual-lists" > /etc/supercronic-cron +echo "${CRON_SCHEDULE} poetry run spotiplex sync-manual-lists" >> /etc/supercronic-cron +# Run the initial commands +poetry run spotiplex sync-lidarr-imports poetry run spotiplex sync-manual-lists -poetry run spotiplex sync-lidarr-imports \ No newline at end of file + +# Start supercronic with the generated crontab +exec supercronic /etc/supercronic-cron diff --git a/poetry.lock b/poetry.lock index 2660b52..f79f10e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -532,13 +532,13 @@ files = [ [[package]] name = "urllib3" -version = "2.2.1" +version = "2.2.2" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.8" files = [ - {file = "urllib3-2.2.1-py3-none-any.whl", hash = "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d"}, - {file = "urllib3-2.2.1.tar.gz", hash = "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19"}, + {file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"}, + {file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"}, ] [package.extras] From 731933fae6ce16237703774018514c0ccc820675 Mon Sep 17 00:00:00 2001 From: 0xChloe Date: Sat, 22 Jun 2024 07:42:47 +0000 Subject: [PATCH 07/12] Added cover art stuff, credit to @AverageDave93 for the idea and some of the code --- spotiplex/modules/plex/main.py | 28 ++++++++++++++++++++++++++-- spotiplex/modules/spotify/main.py | 11 +++++++++++ spotiplex/modules/spotiplex/main.py | 5 ++--- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/spotiplex/modules/plex/main.py b/spotiplex/modules/plex/main.py index 6c91b70..e8cf465 100644 --- a/spotiplex/modules/plex/main.py +++ b/spotiplex/modules/plex/main.py @@ -83,11 +83,25 @@ def match_spotify_tracks_in_plex( logger.debug(f"We are missing these tracks: {missing_tracks}") return matched_tracks + def set_cover_art(self: "PlexClass", playlist: Playlist, cover_url: str) -> None: + """Sets cover art.""" + if cover_url is not None: + try: + playlist.uploadPoster(url=cover_url) + except Exception as e: + logger.error( + f"Couldn't set playlist cover for {playlist}, {cover_url}.", + ) + logger.error( + f"Exception was {e}", + ) + def create_playlist( self: "PlexClass", playlist_name: str, playlist_id: str, tracks: list[Track], + cover_url: str | None, ) -> Playlist | None: """Create a playlist in Plex with the given tracks.""" now = datetime.datetime.now() @@ -105,6 +119,8 @@ def create_playlist( Source is Spotify, Playlist ID: {playlist_id} """, ) + if cover_url is not None: + self.set_cover_art(new_playlist, cover_url) while tracks: iteration_tracks = tracks[:300] @@ -122,6 +138,7 @@ def update_playlist( existing_playlist: Playlist, playlist_id: str, tracks: list, + cover_url: str | None, ) -> Playlist | None: """Update an existing playlist in Plex.""" now = datetime.datetime.now() @@ -131,6 +148,7 @@ def update_playlist( existing_playlist.title, playlist_id, tracks, + cover_url, ) existing_playlist.editSummary( summary=f"Playlist updated by Spotiplex on {now.strftime('%m/%d/%Y')},. Source is Spotify, Playlist ID: {playlist_id}", @@ -155,11 +173,17 @@ def create_or_update_playlist( playlist_name: str, playlist_id: str, tracks: list, + cover_url: str | None, ) -> Playlist | None: """Create or update a playlist in Plex.""" existing_playlist = self.find_playlist_by_name(playlist_name) if existing_playlist is not None and tracks: - return self.update_playlist(existing_playlist, playlist_id, tracks) + return self.update_playlist( + existing_playlist, + playlist_id, + tracks, + cover_url, + ) if tracks: - return self.create_playlist(playlist_name, playlist_id, tracks) + return self.create_playlist(playlist_name, playlist_id, tracks, cover_url) return None diff --git a/spotiplex/modules/spotify/main.py b/spotiplex/modules/spotify/main.py index 36c7d50..8fe3000 100644 --- a/spotiplex/modules/spotify/main.py +++ b/spotiplex/modules/spotify/main.py @@ -55,3 +55,14 @@ def get_playlist_name(self: "SpotifyClass", playlist_id: str) -> str | None: ) logger.debug(f"Error was {e}") return None + + def get_playlist_poster(self: "SpotifyClass", playlist_id: str) -> str | None: + """Tries to get cover art URL and returns None if not found.""" + try: + playlist_data = self.sp.playlist(playlist_id, fields=["images"]) + except Exception as e: + logger.error(f"Error retrieving cover art for playlist {playlist_id}: {e}") + if playlist_data and playlist_data["images"]: + cover_url: str = playlist_data["images"][0]["url"] + return cover_url + return None diff --git a/spotiplex/modules/spotiplex/main.py b/spotiplex/modules/spotiplex/main.py index 4dfda48..c295c52 100644 --- a/spotiplex/modules/spotiplex/main.py +++ b/spotiplex/modules/spotiplex/main.py @@ -86,13 +86,12 @@ def process_playlist(self: "Spotiplex", playlist: str) -> None: playlist_name = f"{playlist_name} {current_date}" spotify_tracks = self.spotify_service.get_playlist_tracks(playlist_id) + cover_url = self.spotify_service.get_playlist_poster(playlist_id) plex_tracks = self.plex_service.match_spotify_tracks_in_plex( spotify_tracks, ) self.plex_service.create_or_update_playlist( - playlist_name, - playlist_id, - plex_tracks, + playlist_name, playlist_id, plex_tracks, cover_url ) logger.debug(f"Processed playlist '{playlist_name}'.") else: From 695bff514cc9bc1f9a171589a0609337b5f0b784 Mon Sep 17 00:00:00 2001 From: 0xChloe Date: Sat, 22 Jun 2024 07:44:17 +0000 Subject: [PATCH 08/12] file cleanup --- generate_crontab.sh | 5 -- old/confighandler.py | 31 ------- old/lidarr.py | 44 ---------- old/main.py | 200 ------------------------------------------- old/plex.py | 99 --------------------- old/requirements.txt | 12 --- old/spotify.py | 45 ---------- 7 files changed, 436 deletions(-) delete mode 100644 generate_crontab.sh delete mode 100644 old/confighandler.py delete mode 100644 old/lidarr.py delete mode 100644 old/main.py delete mode 100644 old/plex.py delete mode 100644 old/requirements.txt delete mode 100644 old/spotify.py diff --git a/generate_crontab.sh b/generate_crontab.sh deleted file mode 100644 index 8d0eca9..0000000 --- a/generate_crontab.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/sh - -# Generate the cron file based on environment variables -echo "${CRON_SCHEDULE} poetry run spotiplex sync-lidarr-imports" > /etc/supercronic-cron -echo "${CRON_SCHEDULE} poetry run spotiplex sync-manual-lists" > /etc/supercronic-cron diff --git a/old/confighandler.py b/old/confighandler.py deleted file mode 100644 index 8bb78f9..0000000 --- a/old/confighandler.py +++ /dev/null @@ -1,31 +0,0 @@ -import rtoml -import os - -config_file = "config.toml" - - -def ensure_config_exists(): - """Ensure the configuration file exists, and create it with default values if it doesn't.""" - if not os.path.exists(config_file): - print("config file missing!") - print("Current Working Directory:", os.getcwd()) - with open(config_file, "w") as file: - rtoml.dump({}, file) - - -def read_config(service): - print("reading config") - ensure_config_exists() # Check if config file exists - with open(config_file, "r") as file: - config = rtoml.load(file) - return config.get(service, {}) - - -def write_config(service, data): - print("writing config") - ensure_config_exists() # Check if config file exists - with open(config_file, "r") as file: - config = rtoml.load(file) - config[service] = data - with open(config_file, "w") as file: - rtoml.dump(config, file) diff --git a/old/lidarr.py b/old/lidarr.py deleted file mode 100644 index bbd8d90..0000000 --- a/old/lidarr.py +++ /dev/null @@ -1,44 +0,0 @@ -import json -from confighandler import read_config -import requests -# test - - -class LidarrAPI: - def __init__(self): - self.config = read_config("lidarr") - self.base_url = self.config.get("url") - self.api_key = self.config.get("api_key") - self.headers = {"X-Api-Key": self.api_key} - - def make_request(self, endpoint_path=""): - full_url = self.base_url + endpoint_path - try: - response = requests.get(full_url, headers=self.headers) - response.raise_for_status() - data = response.json() - with open("data_file.json", "w") as file: - json.dump(data, file, indent=4) - return data - except requests.RequestException as e: - print(f"Error during request: {e}") - return None - - def get_lidarr_playlists(self): - result = self.make_request( - endpoint_path="/api/v1/importlist" - ) # Specify the actual endpoint path for Lidarr API - playlists = [] - - if result: - for entry in result: - if entry.get("listType") == "spotify": - playlists.extend( - [ - field.get("value", []) - for field in entry.get("fields", []) - if field.get("name") == "playlistIds" - ] - ) - - return playlists diff --git a/old/main.py b/old/main.py deleted file mode 100644 index 7d2a264..0000000 --- a/old/main.py +++ /dev/null @@ -1,200 +0,0 @@ -from plex import PlexService -from spotify import SpotifyService -from lidarr import LidarrAPI as lapi -from confighandler import read_config, write_config -import concurrent.futures -from concurrent.futures import ThreadPoolExecutor -import schedule -import time -import os - - -class Spotiplex: - def __init__(self): - self.config = read_config("spotiplex") - self.first_run = self.config.get("first_run") - - if Spotiplex.is_running_in_docker(): - # Fetching configuration from environment variables if running in Docker - spotiplex_config = { - "lidarr_sync": os.environ.get("SPOTIPLEX_LIDARR_SYNC", "True"), - "plex_users": os.environ.get("USERS", ""), - "worker_count": int(os.environ.get("WORKERS", 1)), - "seconds_interval": int(os.environ.get("INTERVAL", 86400)), - "manual_playlists": os.environ.get( - "SPOTIPLEX_MANUAL_PLAYLISTS", "False" - ), - } - write_config("spotiplex", spotiplex_config) - - spotify_config = { - "client_id": os.environ.get("SPOTIFY_API_ID", ""), - "api_key": os.environ.get("SPOTIFY_API_KEY", ""), - } - write_config("spotify", spotify_config) - - plex_config = { - "url": os.environ.get("PLEX_URL", ""), - "api_key": os.environ.get("PLEX_TOKEN", ""), - "replace": os.environ.get("REPLACE", "False"), - } - write_config("plex", plex_config) - - lidarr_config = { - "url": os.environ.get("LIDARR_IP", ""), - "api_key": os.environ.get("LIDARR_TOKEN", ""), - } - write_config("lidarr", lidarr_config) - - print("Configuration set from environment variables.") - elif self.first_run is None or self.first_run == "True": - Spotiplex.configurator(self) - self.config = read_config("spotiplex") - self.spotify_service = SpotifyService() - self.plex_service = PlexService() - self.lidarr_api = lapi() - - self.lidarr_sync = (self.config.get("lidarr_sync", "false")).lower() - self.plex_users = self.config.get("plex_users") - self.user_list = self.plex_users.split(",") if self.plex_users else [] - self.worker_count = int(self.config.get("worker_count")) - self.replace_existing = self.config.get("replace_existing") - self.seconds_interval = int(self.config.get("seconds_interval")) - if self.lidarr_sync == "true": - self.sync_lists = self.lidarr_api.get_lidarr_playlists() - else: - # This should be an array of arrays to be run by multiple 'threads': - # For example: [["playlist1"],["playlist2"],["playlist3","playlist4"]] - self.sync_lists = self.config.get("manual_playlists") - print(f"Attempting to run for {self.sync_lists}") - self.default_user = self.plex_service.plex.myPlexAccount().username - - # If the the user list provided is empty, add the default user from the token - if not self.user_list or len(self.user_list) == 0: - self.user_list.append(self.default_user) - - def process_for_user(self, user): - print(f"processing for user {user}") - if user == self.default_user: - self.plex_service.plex = self.plex_service.plex - print(f"Processing playlists for user: {user}") - print("User matches credentials provided, defaulting.") - else: - print(f"Attempting to switch to user {user}") - self.plex_service.plex = self.plex_service.plex.switchUser(user) - - with ThreadPoolExecutor(max_workers=self.worker_count) as executor: - futures = [ - executor.submit( - self.process_playlist, - playlist, - self.plex_service, - self.spotify_service, - self.replace_existing, - ) - for playlist in self.sync_lists - ] - - for future in concurrent.futures.as_completed(futures): - try: - future.result() - except Exception as e: - print(f"Thread resulted in an error: {e}") - - def is_running_in_docker(): - return os.path.exists("/.dockerenv") - - def run(self): - for user in self.user_list: - self.process_for_user(user) - if self.seconds_interval > 0: - schedule.every(self.seconds_interval).seconds.do(self.run) - while True: - schedule.run_pending() - time.sleep(1) - - def extract_playlist_id(playlist_url): # parse playlist ID from URL if applicable - if "?si=" in playlist_url: - playlist_url = playlist_url.split("?si=")[0] - - return ( - playlist_url.split("playlist/")[1] - if "playlist/" in playlist_url - else playlist_url - ) - - def process_playlist( - self, playlists, plex_service, spotify_service, replace_existing - ): - for playlist in playlists: - try: - playlist_id = Spotiplex.extract_playlist_id(playlist) - print(playlist_id) - playlist_name = spotify_service.get_playlist_name(playlist_id) - spotify_tracks = spotify_service.get_playlist_tracks(playlist_id) - plex_tracks = plex_service.check_tracks_in_plex(spotify_tracks) - plex_service.create_or_update_playlist( - playlist_name, playlist_id, plex_tracks - ) - print(f"Processed playlist '{playlist_name}'.") - except Exception as e: - print(f"Error processing playlist '{playlist}':", e) - - def configurator(self): - # Config for Spotiplex - - print( - "Welcome to Spotiplex! It seems this is your first run of the application, please enter your configuration variables below. Press Enter to continue..." - ) - spotiplex_config = { - "lidarr_sync": input("Enter Lidarr sync option (True/False): "), - "plex_users": input("Enter comma-separated Plex user names: "), - "worker_count": int( - input( - "Enter the number of worker threads (Not recommened to exceed core count. 5 is usually a good value.): " - ) - ), - "seconds_interval": int( - input( - "Enter the interval in seconds for scheduling, set to 0 if you don't want the script to repeat: " - ) - ), - "manual_playlists": input("Enter manual playlists (True/False): "), - } - - # Config for SpotifyService - spotify_config = { - "client_id": input("Enter Spotify client ID: "), - "api_key": input("Enter Spotify API key: "), - } - write_config("spotify", spotify_config) - - # Config for PlexService - plex_config = { - "url": input("Enter Plex server URL: "), - "api_key": input("Enter Plex API key: "), - "replace": input("Replace existing Plex data? (True/False): "), - } - write_config("plex", plex_config) - - lidarr_config = { - "url": input("Enter Lidarr URL: "), - "api_key": input("Enter Lidarr API Key: "), - } - write_config("lidarr", lidarr_config) - - spotiplex_config["first_run"] = "False" - write_config("spotiplex", spotiplex_config) - - print("Configuration complete!") - - -def main(): - spotiplex = Spotiplex() - spotiplex.run() - - -if __name__ == "__main__": - start_time = time.time() - main() - print("--- %s seconds ---" % (time.time() - start_time)) diff --git a/old/plex.py b/old/plex.py deleted file mode 100644 index 38926e7..0000000 --- a/old/plex.py +++ /dev/null @@ -1,99 +0,0 @@ -import requests -import urllib3 -from plexapi.server import PlexServer -from plexapi.exceptions import BadRequest, NotFound -from confighandler import read_config -import copy - - -class PlexService: - def __init__(self): - self.config = read_config("plex") - self.server_url = self.config.get("url") - self.server_token = self.config.get("api_key") - self.plex = self.connect_plex() - self.replace = self.config.get("replace") - - def connect_plex(self): - session = requests.Session() - session.verify = False - urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) - return PlexServer(self.server_url, self.server_token, session=session) - - def check_tracks_in_plex(self, spotify_tracks): - print("Checking tracks in plex") - music_lib = self.plex.library.section("Music") - plex_tracks = [] - orig_tracks = [] - - for track_name, artist_name in spotify_tracks: - artist_tracks_in_plex = music_lib.search(title=artist_name) - if artist_tracks_in_plex: - for track in artist_tracks_in_plex: - try: - plex_track = track.track(title=track_name) - - # Once we find a matching track, we want to break out of this iteration to get to the next song - if plex_track: - break - except NotFound: - # This is not really an exception, just continue - continue - except (Exception, BadRequest) as plex_search_exception: - print(f"Exception trying to search for title={artist_name}, title={track_name}") - print(plex_search_exception) - # While this is a fatal exception to the specific search, continue on since a subsequent match may succeed - continue - - if plex_track: - plex_tracks.append(plex_track) - else: - print("Song not in plex!") - print(f"Found artists for '{artist_name}' ({len(artist_tracks_in_plex)})") - print(f"Attempted to match song '{track_name}', but could not!") - orig_tracks.append([track_name, "Song Not in Plex"]) - else: - print(f"No results found for artist: {artist_name}") - continue - - print(f"Found {len(plex_tracks)} of possible {len(spotify_tracks)} (Failed to find {len(orig_tracks)})") - - return plex_tracks - - def create_or_update_playlist( - self, playlist_name, playlist_id, tracks - ): - existing_playlist = self.find_playlist_by_name(playlist_name) - if existing_playlist: - if self.replace: - existing_playlist.delete() - return self.create_playlist(playlist_name, playlist_id, tracks) - else: - existing_playlist.addItems(tracks) - return existing_playlist - else: - return self.create_playlist(playlist_name, playlist_id, tracks) - - def find_playlist_by_name(self, playlist_name): - playlists = self.plex.playlists() - for playlist in playlists: - if playlist.title == playlist_name: - return playlist - return None - -# Playlist is created in intervals of 300 since Plex's API will return a 414 URL TOO LONG inconsistently past that - def create_playlist(self, playlist_name, playlist_id, tracks): - tracks_to_add = copy.deepcopy(tracks) - try: - iteration_tracks = tracks_to_add[:300] - del tracks_to_add[:300] - new_playlist = self.plex.createPlaylist(playlist_name, items=iteration_tracks) - - while len(tracks_to_add) > 0: - iteration_tracks = tracks_to_add[:300] - del tracks_to_add[:300] - new_playlist.addItems(iteration_tracks) - return new_playlist - except Exception as e: - print(f"Error creating playlist {playlist_name}: {e}") - return None diff --git a/old/requirements.txt b/old/requirements.txt deleted file mode 100644 index 3b3cae7..0000000 --- a/old/requirements.txt +++ /dev/null @@ -1,12 +0,0 @@ -async-timeout==4.0.3 ; python_version >= "3.10" and python_full_version <= "3.11.2" -certifi==2023.11.17 ; python_version >= "3.10" and python_version < "4.0" -charset-normalizer==3.3.2 ; python_version >= "3.10" and python_version < "4.0" -idna==3.6 ; python_version >= "3.10" and python_version < "4.0" -plexapi==4.15.7 ; python_version >= "3.10" and python_version < "4.0" -redis==5.0.1 ; python_version >= "3.10" and python_version < "4.0" -requests==2.31.0 ; python_version >= "3.10" and python_version < "4.0" -rtoml==0.10.0 ; python_version >= "3.10" and python_version < "4.0" -schedule==1.2.1 ; python_version >= "3.10" and python_version < "4.0" -six==1.16.0 ; python_version >= "3.10" and python_version < "4.0" -spotipy==2.23.0 ; python_version >= "3.10" and python_version < "4.0" -urllib3==2.1.0 ; python_version >= "3.10" and python_version < "4.0" diff --git a/old/spotify.py b/old/spotify.py deleted file mode 100644 index 67a0c89..0000000 --- a/old/spotify.py +++ /dev/null @@ -1,45 +0,0 @@ -import spotipy -from spotipy.oauth2 import SpotifyClientCredentials -from datetime import date -from confighandler import read_config - - -class SpotifyService: - def __init__(self): - self.config = read_config("spotify") - self.client_id = self.config.get("client_id") - self.client_secret = self.config.get("api_key") - self.sp = self.connect_spotify() - - def connect_spotify(self): - auth_manager = SpotifyClientCredentials( - client_id=self.client_id, client_secret=self.client_secret - ) - return spotipy.Spotify(auth_manager=auth_manager) - - def get_playlist_tracks(self, playlist_id): - tracks = [] - try: - results = self.sp.playlist_tracks(playlist_id) - while results: - tracks.extend( - [ - (item["track"]["name"], item["track"]["artists"][0]["name"]) - for item in results["items"] - ] - ) - results = self.sp.next(results) if results["next"] else None - except Exception as e: - print(f"Error fetching tracks from Spotify: {e}") - return tracks - - def get_playlist_name(self, playlist_id): - try: - playlist_data = self.sp.playlist(playlist_id, fields=["name"]) - name = playlist_data["name"] - if "Discover Weekly" in name or "Daily Mix" in name: - name = f"{name} {date.today()}" - return name - except Exception as e: - print(f"Error retrieving playlist name: {e}") - return None From 31a13dcaf9f18850c075d221ea4f5a749fedec61 Mon Sep 17 00:00:00 2001 From: 0xChloe Date: Sat, 22 Jun 2024 07:55:56 +0000 Subject: [PATCH 09/12] rewrote env demo --- default.env | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/default.env b/default.env index 954dba5..f93f70f 100644 --- a/default.env +++ b/default.env @@ -1,12 +1,13 @@ -SPOTIPLEX_LIDARR_SYNC=True -USERS= -WORKERS=5 -REPLACE=False -INTERVAL=86400 -SPOTIPLEX_MANUAL_PLAYLISTS= SPOTIFY_API_KEY= SPOTIFY_API_ID= -PLEX_URL= -PLEX_TOKEN= -LIDARR_IP= -LIDARR_TOKEN= \ No newline at end of file +PLEX_API_KEY= +PLEX_SERVER_URL= +PLEX_REPLACE= +LIDARR_API_KEY= +LIDARR_API_URL= +PLEX_USERS=user1,user2,user3 +WORKER_COUNT=4 #unless you have issues, 4 is a safe default. +# SECONDS_INTERVAL=60 !!--DEPRECATED--!! +MANUAL_PLAYLISTS= +LIDARR_SYNC=False +CRON_SCHEDULE=0 1 * * * #Takes values that are compatible with supercronic. @daily recommended \ No newline at end of file From fc22f49b4b024fad6fc79cbe9aa65a949e3b8932 Mon Sep 17 00:00:00 2001 From: 0xChloe Date: Sat, 22 Jun 2024 08:00:15 +0000 Subject: [PATCH 10/12] Update docs --- README.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 512c195..0510322 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ - [Known Issues](#known-issues) - [Disclaimer](#disclaimer) -**Note:** Temporarily change-frozen, building a new app that implements similar functionality for Radarr, Sonarr, etc, using Trakt and IMDB lists or *arr tags. Will be implemented with a web interface from the start, so is naturally taking a bit more time. [New app repo is here](https://github.com/cmathews393/plex-playlist-manager). +**Note:** Temporarily change-frozen (except for all the times I've updated this instead of working on Playlist Manager), building a new app that implements similar functionality for Radarr, Sonarr, etc, using Trakt and IMDB lists or *arr tags. Will be implemented with a web interface from the start, so is naturally taking a bit more time. [New app repo is here](https://github.com/cmathews393/plex-playlist-manager). ## How To @@ -25,7 +25,7 @@ - Get Plex API key. - Get Spotify ID and API key. - Get Lidarr API key. -3. Run with poetry (`cd spotiplex && poetry install`, `poetry run python main.py`) +3. Run with poetry (`cd spotiplex && poetry install`, `poetry run spotiplex --help`) 4. Follow CLI prompts ### Setup (Docker Version) @@ -36,17 +36,15 @@ Note: Tested only on Linux, Docker version 24.0.5. Other environments are "unsup 2. `touch spotiplex.env` 3. Copy the contents of `default.env` from this repo to your new `.env` file, and edit as needed. 4. `docker run --env-file spotiplex.env 0xchloe/spotiplex` -5. Re-start the container to re-sync. Manual playlist syncing can be accomplished by including manual playlists in the .env file +5. Container will run sync of Lidarr lists and manually specified lists on initial start, and every day at midnight if CRON_SCHEDULE is not set ## Dependencies -Using [python-plexapi](https://github.com/pkkid/python-plexapi), [spotipy](https://github.com/spotipy-dev/spotipy), [rtoml](https://github.com/samuelcolvin/rtoml) , [schedule](https://github.com/dbader/schedule) +Using [python-plexapi](https://github.com/pkkid/python-plexapi), [spotipy](https://github.com/spotipy-dev/spotipy), [rtoml](https://github.com/samuelcolvin/rtoml), typer, supercronic, httpx ## Upcoming Planned Features - Add to Plex-Playlist-Manager (See above) -- Add fuzzy search to Plex -- Troubleshoot Spotify API issues (see below) ## Known Issues From d54a5264c94f9e303d60c0f62becd6c8bac6f55b Mon Sep 17 00:00:00 2001 From: 0xChloe Date: Sat, 22 Jun 2024 18:15:32 +0000 Subject: [PATCH 11/12] I forgot how cron worked. Defaulting schedule to daily now also. --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index bb43883..6cb8894 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ FROM python:latest ENV SRC_DIR /usr/bin/spotiplex/ ENV POETRY_VERSION=1.7.1 ENV PYTHONUNBUFFERED=1 -ENV CRON_SCHEDULE=@hourly +ENV CRON_SCHEDULE=@daily ENV DOCKER=True # Install Poetry RUN pip install "poetry==$POETRY_VERSION" From efeeea552ff7e2cb43f8e6082d5f3f9d81e8de76 Mon Sep 17 00:00:00 2001 From: 0xChloe Date: Sat, 22 Jun 2024 18:35:52 +0000 Subject: [PATCH 12/12] adding compose --- compose.yaml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 compose.yaml diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..08bceb8 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,6 @@ +version: "3.7" +services: + spotiplex: + restart: unless-stopped + image: docker.io/0xchloe/spotiplex:latest + container_name: spotiplex-latest