├── .dockerignore ├── .github └── workflows │ └── docker.yml ├── .gitignore ├── Dockerfile ├── README.md ├── app.py ├── app_backup.py ├── app_restore_backup.py ├── config.cfg ├── images ├── ACdb_Official_Collections.png ├── ACdb_collection_settings.png ├── ACdb_missing_items.png ├── ACdb_plugin_install.png ├── ACdb_seasonal_collections.png └── banner.jpeg ├── requirements.txt └── src ├── date_parser.py ├── db.py ├── emby.py ├── item_sorting.py ├── mdblist.py ├── refresher.py └── utils.py /.dockerignore: -------------------------------------------------------------------------------- 1 | # Git 2 | .git 3 | .gitignore 4 | .gitattributes 5 | 6 | # CI 7 | .codeclimate.yml 8 | .travis.yml 9 | .taskcluster.yml 10 | 11 | # Docker 12 | docker-compose.yml 13 | Dockerfile 14 | .docker 15 | .dockerignore 16 | 17 | # Byte-compiled / optimized / DLL files 18 | **/__pycache__/ 19 | **/*.py[cod] 20 | 21 | # Distribution / packaging 22 | .Python 23 | env/ 24 | build/ 25 | develop-eggs/ 26 | dist/ 27 | downloads/ 28 | eggs/ 29 | lib/ 30 | lib64/ 31 | parts/ 32 | sdist/ 33 | var/ 34 | *.egg-info/ 35 | .installed.cfg 36 | *.egg 37 | 38 | # PyInstaller 39 | # Usually these files are written by a python script from a template 40 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 41 | *.manifest 42 | *.spec 43 | 44 | # Installer logs 45 | pip-log.txt 46 | pip-delete-this-directory.txt 47 | 48 | # Sphinx documentation 49 | docs/_build/ 50 | 51 | # PyBuilder 52 | target/ 53 | 54 | # Virtual environment 55 | .env 56 | .venv/ 57 | venv/ 58 | 59 | # VS Code 60 | .vscode/ 61 | 62 | # Project specific 63 | config_hidden.cfg -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image 2 | 3 | on: 4 | push: 5 | branches: 6 | - main # Trigger on pushes to the main branch 7 | 8 | jobs: 9 | build_and_push: 10 | runs-on: ubuntu-latest 11 | 12 | permissions: 13 | contents: read 14 | packages: write 15 | attestations: write 16 | id-token: write 17 | 18 | steps: 19 | # This step is a workaround for an issue whereby github artifacts expects repositories to have only lower case names 20 | # See https://github.com/docker/build-push-action/issues/37 21 | - name: PrepareReg Names 22 | run: | 23 | echo IMAGE_REPOSITORY=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV 24 | echo IMAGE_TAG=$(echo ${{ github.ref }} | tr '[:upper:]' '[:lower:]' | awk '{split($0,a,"/"); print a[3]}') >> $GITHUB_ENV 25 | 26 | - name: Checkout code 27 | uses: actions/checkout@v4 28 | 29 | - name: Login to GitHub Container Registry 30 | uses: docker/login-action@v2 31 | with: 32 | registry: ghcr.io 33 | username: ${{ github.repository_owner }} 34 | password: ${{ secrets.GITHUB_TOKEN }} 35 | 36 | - name: Build and push with commit SHA tag 37 | uses: docker/build-push-action@v3 38 | with: 39 | context: . 40 | push: true 41 | tags: ghcr.io/${{ env.IMAGE_REPOSITORY }}:latest, ghcr.io/${{ env.IMAGE_REPOSITORY }}:${{ github.sha }} 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config_hidden* 2 | __pycache__/ 3 | backup/ 4 | notes.txt 5 | .vscode/launch.json 6 | test.py 7 | temp/ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3 2 | 3 | WORKDIR /app 4 | 5 | COPY requirements.txt . 6 | 7 | RUN pip install --no-cache-dir -r requirements.txt 8 | 9 | COPY . . 10 | 11 | CMD ["python", "app.py"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](images/banner.jpeg) 2 | 3 | # Emby MDBList Collection Creator 1.84 4 | 5 | This tool allows you to convert lists from MDBList.com into collections within your Emby media server. MDBList aggregates content lists from various platforms including Trakt and IMDB. 6 | 7 | ## Plugin version now available! 8 | 9 | If you prefer to make life easier you can use the [Plugin Version on ACdb.tv Automated Collections](https://acdb.tv/). Read more at the bottom of readme. If not, continue reading! 10 | 11 | ## Features 12 | 13 | * List Conversion: Transform MDBList lists into Emby collections 14 | * Metadata Refresh: Keep ratings up-to-date for newly released content 15 | * Collection Images: Upload local or remote images for collections posters 16 | * Seasonal Collections: Specify when a collection should be visible 17 | * Collection Ordering: Show your collections in order of which one was updated last 18 | * Collection Description: Add description from MDBList or create your own 19 | * Backup & Restore: Additional utilities to backup and restore watch history and favorites 20 | 21 | ## Support me 22 | 23 | * Please consider [subscribing to my Patreon](https://www.patreon.com/c/acdbtv) even though you use this script and not ACdb. 24 | 25 | ## Prerequisites: 26 | 27 | To use this script, you need: 28 | 29 | * At minimum Python 3.11 installed on your system 30 | * "Requests" Python package (install with `pip install requests`) 31 | * Admin privileges on Emby 32 | * A user account on [MDBList](https://mdblist.com/) 33 | * The script has been tested with Emby Version 4.8.8.0, but other recent versions should also be compatible 34 | 35 | ## Usage 36 | 37 | ### Configuring the Admin Section 38 | 39 | In the `config.cfg` file, fill in the following details: 40 | 41 | * Emby server URL 42 | * Emby admin user ID 43 | * Emby API key 44 | * MDBList API key 45 | 46 | Refer to the comments in `config.cfg` for additional information. 47 | 48 | If config_hidden.cfg exists it will be used instead. Just copy paste the contents of config.cfg into config_hidden.cfg and make your changes there. When updating the script, you can safely overwrite config.cfg without losing your settings. 49 | 50 | ### Running the Script 51 | 52 | To run the script, follow these steps: 53 | 54 | 1. **Open a Command Prompt or Terminal:** 55 | - On Windows, press `Win + R`, type `cmd`, and press Enter. 56 | - On macOS or Linux, open the Terminal application. 57 | 58 | 2. **Navigate to the Project Directory:** 59 | - Use the `cd` command to change to the directory where the script is located. For example: 60 | ```bash 61 | cd /path/to/Emby-MDBList-Collection-Creator 62 | ``` 63 | 64 | 3. **Install Required Packages:** 65 | - Ensure you have Python installed. You can download it from [python.org](https://www.python.org/). 66 | - Install the required Python package by running: 67 | ```bash 68 | pip install requests 69 | ``` 70 | 71 | 4. **Run the Script:** 72 | - Execute the script by running: 73 | ```bash 74 | python app.py 75 | ``` 76 | 77 | If you encounter any issues, ensure you have followed each step correctly and have the necessary permissions. 78 | 79 | 80 | ### Running the Script in Docker 81 | 82 | To run the script in a Docker container, you will need to mount a configuration file to `/app/config.cfg` but that's it - no ports need exposing. 83 | 84 | An example docker run command: 85 | 86 | ```bash 87 | docker run -v /path/to/config.cfg:/app/config.cfg ghcr.io/jonjonsson/emby-mdblist-collection-creator 88 | ``` 89 | 90 | Or use this example compose file: 91 | 92 | ```yml 93 | version: '3.8' 94 | 95 | services: 96 | emby-mdblist-collection-creator: 97 | image: ghcr.io/jonjonsson/emby-mdblist-collection-creator:latest 98 | volumes: 99 | - /path/to/config.cfg:/app/config.cfg 100 | ``` 101 | 102 | 103 | ### Building the docker image manually 104 | 105 | It's not necessary to build the image yourself, but should you choose to, you need these two commands: 106 | 107 | 1. Build the Docker image: 108 | 109 | ```bash 110 | docker build -t emby-mdblist . --load 111 | ``` 112 | 113 | 2. Run the Docker container, passing in the config file via a volume mount: 114 | 115 | ```bash 116 | docker run -v .\config_hidden.cfg:/app/config.cfg emby-mdblist 117 | ``` 118 | 119 | (Use `./config_hidden.cfg` if you are on a Unix-based system) 120 | 121 | ## Creating Emby Collections from MDBList Lists 122 | 123 | There are two methods to create Emby collections from MDBList lists: 124 | 125 | ### 1. Add MDBList URLs to `config.cfg` or `config_hidden.cfg` 126 | 127 | * Refer to `config.cfg` for examples. 128 | * This method allows you to create collections from other users' lists, found at [MDBList Top Lists](https://mdblist.com/toplists/) for example. 129 | * The `config.cfg` file contains examples. Use these as a guide to add more lists. 130 | 131 | ### 2. Automatically Download Your Lists from MDBList 132 | 133 | By creating your own lists on MDBList (found at [My MDBList](https://mdblist.com/mylists/)), Emby collections will be automatically created from your saved lists. This feature can be turned off in `config.cfg`. Please ensure your newly created MDBLists populate items before running the script. 134 | 135 | ## Sorting Shows and Movies by time added 136 | 137 | This feature is off by default. Emby can not sort items inside collections by time added to library. That means you can't sort collections to show what is newest first. This is a shame if you have a Trending Movies collection for example and you can't see what's new at a glance. 138 | 139 | To address this you can have this script update the sort names of items that are in collections. It updates item sort name in the metadata so that the sort name is appended with "!!![number_of_minutes_until_year_2100_from_the_date_time_added_to_emby]". That way the newest items show first when sorted in the default alphabetical order. This will affect the sorting of these items elsewhere as well which you may or may not care about, you can always turn it off later and the old sort name will be restored on the next run of the script. 140 | 141 | You can set this on by default for all collection in the config or set it per collection. 142 | 143 | When an item is no longer in a collection that requires it to have a custom sort name the old sort name is restored. 144 | 145 | ## Keeping rating up to date for newly released items 146 | Helps to keep the ratings of newly released movies and shows up to date until the rating settles a bit on IMDB etc. See more in config.cfg. 147 | 148 | ## Seasonal lists 149 | You can specify a period of the year to show a collection for. For example only show a collection during Christmas every year. You can also specify an end date so a collection does not show again after a specific date, useful for something like this years Oscars collection that you don't want to be hanging about forever. See example in config file. 150 | 151 | ## Collection posters 152 | Specify the image to use as a collection poster, either a local image or an image url. See examples in config.cfg. 153 | 154 | ## Backing up IsWatched and Favorites 155 | Kind of a bolted on functionality since it's unrelated to the main function of the script, but I needed it so I added it. 156 | 157 | ### Backing up 158 | Run app_backup.py to save IsWatched and Favorites for all users to json files, the files will be saved to a "backup" directory. If you only want to use this functionality it's enough to fill out "emby_server_url", "emby_user_id" and "emby_api_key" in the config file. 159 | 160 | ### Restoring backup 161 | Run app_restore_backup.py to restore IsWatched and Favorites to ANOTHER server, see comments at top of app_restore_backup.py. 162 | 163 | ## Frequently Asked Questions 164 | 165 | - **Is there a plugin version available?** 166 | - Yes, see [ACdb.tv Automated Collections](https://acdb.tv/). More information at the end of the readme. 167 | 168 | - **What happens if I rename my collection in Emby or this script?** 169 | - A new collection will be created with the name you specify in the config file and the renamed collection will be ignored by the script. 170 | 171 | - **Does this affect my manually created collection?** 172 | - This will only affect collections with the same name as specified in the config file. 173 | 174 | - **Do I need a server to use this script?** 175 | - No, you can run it on your Windows or Mac PC and just keep it open. The script refreshes the collections every n hours as specified in config.cfg. 176 | 177 | - **Do the collections show for all Emby users?** 178 | - Yes, the collections will be visible to all Emby users. 179 | 180 | ## Changelog 181 | 182 | ### Version 1.1 183 | Can use lists by specifying MDBList name and user name of creator instead of having to know the ID. No change required for config.cfg. See example config.cfg on how to use it. 184 | 185 | ### Version 1.2 186 | Optionally change the sort name of Emby Collections so that the collections that get modified are ordered first. On by default. Optionally add "update_collection_sort_name = False" to config.cfg to disable. 187 | 188 | ### Version 1.3 189 | Optionally set sort names of items so that the newest items show first in the collection. 190 | 191 | ### Version 1.4 192 | Optionally refresh metadata for newly added media. Added 2 scripts to backup IsWatched and Favorites for all users. 193 | 194 | ### Version 1.5 195 | New preferred method of adding lists by using the mdblist URL instead of the older method of specifying the ID or list name + author. No config file update is required, old methods will still work. See config.cfg for more info. 196 | 197 | ### Version 1.6 198 | Added support for multiple MDBList urls for a single collection. 199 | 200 | ### Version 1.61 + 1.62 201 | Fix for item sort names not being updated unless collection existed prior. Additional error handling. 202 | 203 | ### Version 1.7 204 | Added ability to have seasonal or temporary lists like for Halloween, Christmas, Oscars etc. Thanks to @cj0r for the idea. See example in config file. No breaking changes for any older version. 205 | 206 | ### Version 1.71 207 | Added Docker support thanks to @neoKushan. Minor fix for seasonal lists. 208 | 209 | ### Version 1.8 210 | Added ability to set collection posters. 211 | 212 | ### Version 1.81 213 | Can set custom sort name for a collection. Use "collection_sort_name" in config. 214 | 215 | ### Version 1.82 216 | Optionally set "use_mdblist_collection_description = True" to grab the descriptions from MDBList. Applies to all collections. 217 | Optionally set "description" for each collection to set your own custom description. Will overwrite MDBList description if set. 218 | See examples in config.cfg. 219 | 220 | ### Version 1.83 221 | * Now using newer version of MDBList API. 222 | * Added some new MDBLIst methods which may be helpful in the future. 223 | * Renamed some variables to avoid naming conflicts. 224 | * Updated Readme to include information about ACdb.tv. 225 | 226 | ### Version 1.84 227 | Important update if you have lists with more than 1000 items. Addressing changes to MDBList API that now limits items requests to 1000 at a time. 228 | 229 | 230 | # 🚀 Try the ACdb.tv Automated Collections Plugin for Emby! 231 | 232 | ## Looking for an easier way to sync MDBList collections with Emby? 233 | Check out the [ACdb.tv Automated Collections plugin](https://acdb.tv/) — no Python or manual setup required! Install directly from the Emby plugin catalog and manage everything from ACdb.tv. 234 | 235 | ![ACdb.tv collection repository](images/ACdb_Official_Collections.png) 236 | 237 | ## Key Features: 238 | - Easily install the plugin from Emby Plugin Catalog 239 | - Easy webapp-based configuration at [acdb.tv](https://acdb.tv/) instead of fiddling with config files. 240 | - Dynamic movie & TV collections synced automatically 241 | - Scheduled/seasonal collections (show only during specific periods) 242 | - Custom collection ordering (move updated collections to the top) 243 | - Ability to see which items you have and do not have in a collection (Patreon feature) 244 | - Coming soon: Collection posters with a poster gallery 245 | 246 | ## How to Get Started: 247 | ![ACdb.tv Plugin install](images/ACdb_plugin_install.png) 248 | - Install from the Emby plugin catalog (see [Getting Started](https://acdb.tv/)) 249 | - Free: 3 collections 250 | - $2/month: Up to 20 collections, any MDBList, full customization 251 | 252 | ### Seasonal Collection in ACdb.tv 253 | ![ACdb.tv seasonal collections](images/ACdb_seasonal_collections.png) 254 | 255 | ### View which items you have to do not have from a collection 256 | ![ACdb.tv see which items you have, and](images/ACdb_missing_items.png) 257 | 258 | ### Modify collection sort order, schedule and more 259 | ![ACdb.tv example of collection settings](images/ACdb_collection_settings.png) 260 | 261 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import random 2 | import time 3 | import configparser 4 | import requests 5 | from src.emby import Emby 6 | from src.item_sorting import ItemSorting 7 | from src.refresher import Refresher 8 | from src.mdblist import Mdblist 9 | from src.date_parser import inside_period 10 | from src.db import Db 11 | from src.utils import find_missing_entries_in_list 12 | from src.utils import minutes_until_2100 13 | 14 | config_parser = configparser.ConfigParser() 15 | config_parser.optionxform = str.lower 16 | 17 | # Check if config_hidden.cfg exists, if so, use that, otherwise use config.cfg 18 | if config_parser.read("config_hidden.cfg", encoding="utf-8") == []: 19 | config_parser.read("config.cfg") 20 | 21 | emby_server_url = config_parser.get("admin", "emby_server_url") 22 | emby_user_id = config_parser.get("admin", "emby_user_id") 23 | emby_api_key = config_parser.get("admin", "emby_api_key") 24 | mdblist_api_key = config_parser.get("admin", "mdblist_api_key") 25 | download_manually_added_lists = config_parser.getboolean( 26 | "admin", "download_manually_added_lists", fallback=True 27 | ) 28 | download_my_mdblist_lists_automatically = config_parser.getboolean( 29 | "admin", "download_my_mdblist_lists_automatically", fallback=True 30 | ) 31 | update_collection_sort_name = config_parser.getboolean( 32 | "admin", "update_collection_sort_name", fallback=True 33 | ) 34 | update_items_sort_names_default_value = config_parser.getboolean( 35 | "admin", "update_items_sort_names_default_value", fallback=False 36 | ) 37 | refresh_items = config_parser.getboolean( 38 | "admin", "refresh_items_in_collections", fallback=False 39 | ) 40 | refresh_items_max_days_since_added = config_parser.getint( 41 | "admin", "refresh_items_in_collections_max_days_since_added", fallback=10 42 | ) 43 | refresh_items_max_days_since_premiered = config_parser.getint( 44 | "admin", "refresh_items_in_collections_max_days_since_premiered", fallback=30 45 | ) 46 | use_mdblist_collection_description = config_parser.getboolean( 47 | "admin", "use_mdblist_collection_description", fallback=False 48 | ) 49 | 50 | hours_between_refresh = config_parser.getint("admin", "hours_between_refresh") 51 | 52 | newly_added = 0 53 | newly_removed = 0 54 | collection_ids_with_custom_sorting = [] 55 | all_collections_ids = [] 56 | 57 | emby = Emby(emby_server_url, emby_user_id, emby_api_key) 58 | mdblist = Mdblist(mdblist_api_key) 59 | item_sorting = ItemSorting(emby) 60 | refresher = Refresher(emby) 61 | db_manager = Db() 62 | 63 | 64 | def process_list(mdblist_list: dict): 65 | global newly_added 66 | global newly_removed 67 | collection_name = mdblist_list["name"] 68 | frequency = int(mdblist_list.get("frequency", 100)) 69 | list_id = mdblist_list.get("id", None) 70 | source = mdblist_list.get("source", None) 71 | poster = mdblist_list.get("poster", None) 72 | mdblist_name = mdblist_list.get("mdblist_name", None) 73 | user_name = mdblist_list.get("user_name", None) 74 | update_collection_items_sort_names = mdblist_list.get( 75 | "update_items_sort_names", update_items_sort_names_default_value 76 | ) 77 | collection_sort_name = mdblist_list.get("collection_sort_name", None) 78 | description = mdblist_list.get("description", None) # Description from mdblist 79 | overwrite_description = mdblist_list.get("overwrite_description", None) # From cfg 80 | 81 | collection_id = emby.get_collection_id(collection_name) 82 | active_period_str = config_parser.get( 83 | collection_name, "active_between", fallback=None 84 | ) 85 | 86 | if active_period_str: 87 | if not inside_period(active_period_str): 88 | all_items_in_collection = emby.get_items_in_collection( 89 | collection_id, ["Id"] 90 | ) 91 | item_ids = ( 92 | [item["Id"] for item in all_items_in_collection] 93 | if all_items_in_collection is not None 94 | else [] 95 | ) 96 | newly_removed += emby.delete_from_collection(collection_name, item_ids) 97 | if newly_removed > 0: 98 | print(f"Collection {collection_name} is not active. Removed all items.") 99 | print("=========================================") 100 | return 101 | 102 | if collection_id is None: 103 | print(f"Collection {collection_name} does not exist. Will create it.") 104 | frequency = 100 # If collection doesn't exist, download every time 105 | 106 | print() 107 | print("=========================================") 108 | 109 | if random.randint(0, 100) > frequency: 110 | print(f"Skipping mdblist {collection_name} since frequency is {frequency}") 111 | print("=========================================") 112 | return 113 | 114 | mdblist_imdb_ids = [] 115 | mdblist_mediatypes = [] 116 | if list_id is not None: 117 | mdblist_imdb_ids, mdblist_mediatypes = mdblist.get_list(list_id) 118 | elif mdblist_name is not None and user_name is not None: 119 | found_list_id = mdblist.find_list_id_by_name_and_user(mdblist_name, user_name) 120 | if found_list_id is None: 121 | print(f"ERROR! List {mdblist_name} by {user_name} not found. Skipping.") 122 | print("=========================================") 123 | return 124 | mdblist_imdb_ids, mdblist_mediatypes = mdblist.get_list(found_list_id) 125 | elif source is not None: 126 | source = source.replace(" ", "") 127 | sources = source.split(",http") 128 | # Add http back to all but the first source 129 | sources = [sources[0]] + [f"http{url}" for url in sources[1:]] 130 | for url in sources: 131 | imdb_ids, mediatypes = mdblist.get_list_using_url(url.strip()) 132 | mdblist_imdb_ids.extend(imdb_ids) 133 | mdblist_mediatypes.extend(mediatypes) 134 | else: 135 | print(f"ERROR! Must provide either id or source for {collection_name}.") 136 | print("=========================================") 137 | return 138 | 139 | if mdblist_imdb_ids is None: 140 | print(f"ERROR! No items in {collection_name}. Will not process this list.") 141 | print("=========================================") 142 | return 143 | 144 | remove_emby_ids = [] 145 | missing_imdb_ids = [] 146 | 147 | if len(mdblist_imdb_ids) == 0: 148 | print( 149 | f"ERROR! No items in mdblist {collection_name}. Will not process this list. Perhaps you need to wait for it to populate?" 150 | ) 151 | print("=========================================") 152 | return 153 | 154 | mdblist_imdb_ids = list(set(mdblist_imdb_ids)) # Remove duplicates 155 | print(f"Processing {collection_name}. List has {len(mdblist_imdb_ids)} IMDB IDs") 156 | collection_id = emby.get_collection_id(collection_name) 157 | 158 | if collection_id is None: 159 | missing_imdb_ids = mdblist_imdb_ids 160 | else: 161 | try: 162 | collection_items = emby.get_items_in_collection( 163 | collection_id, ["ProviderIds"] 164 | ) 165 | except Exception as e: 166 | print(f"Error getting items in collection: {e}") 167 | print("=========================================") 168 | return 169 | 170 | collection_imdb_ids = [item["Imdb"] for item in collection_items] 171 | missing_imdb_ids = find_missing_entries_in_list( 172 | collection_imdb_ids, mdblist_imdb_ids 173 | ) 174 | 175 | for item in collection_items: 176 | if item["Imdb"] not in mdblist_imdb_ids: 177 | remove_emby_ids.append(item["Id"]) 178 | 179 | # Need Emby Item Ids instead of IMDB IDs to add to collection 180 | add_emby_ids = emby.get_items_with_imdb_id(missing_imdb_ids, mdblist_mediatypes) 181 | 182 | print() 183 | print(f"Added {len(add_emby_ids)} new items and removed {len(remove_emby_ids)}") 184 | 185 | if collection_id is None: 186 | if len(add_emby_ids) == 0: 187 | print(f"ERROR! No items to put in mdblist {collection_name}.") 188 | print("=========================================") 189 | return 190 | # Create the collection with the first item since you have to create with an item 191 | collection_id = emby.create_collection(collection_name, [add_emby_ids[0]]) 192 | add_emby_ids.pop(0) 193 | 194 | if collection_id not in all_collections_ids: 195 | all_collections_ids.append(collection_id) 196 | 197 | if update_collection_items_sort_names is True: 198 | collection_ids_with_custom_sorting.append(collection_id) 199 | 200 | items_added = emby.add_to_collection(collection_name, add_emby_ids) 201 | newly_added += items_added 202 | newly_removed += emby.delete_from_collection(collection_name, remove_emby_ids) 203 | 204 | set_poster(collection_id, collection_name, poster) 205 | 206 | if collection_sort_name is not None: 207 | emby.set_item_property(collection_id, "ForcedSortName", collection_sort_name) 208 | 209 | # Change sort name so that it shows up first. 210 | # TODO If True previously and now False, it will not reset the sort name 211 | elif ( 212 | update_collection_sort_name is True 213 | and collection_sort_name is None 214 | and items_added > 0 215 | ): 216 | collection_sort_name = f"!{minutes_until_2100()} {collection_name}" 217 | emby.set_item_property(collection_id, "ForcedSortName", collection_sort_name) 218 | print(f"Updated sort name for {collection_name} to {collection_sort_name}") 219 | 220 | if ( 221 | use_mdblist_collection_description is True 222 | and bool(description) 223 | and overwrite_description is None 224 | ): 225 | emby.set_item_property(collection_id, "Overview", description) 226 | elif overwrite_description is not None: 227 | emby.set_item_property(collection_id, "Overview", overwrite_description) 228 | 229 | print("=========================================") 230 | 231 | 232 | def process_my_lists_on_mdblist(): 233 | my_lists = mdblist.get_my_lists() 234 | if len(my_lists) == 0: 235 | print("ERROR! No lists returned from MDBList API. Will not process any lists.") 236 | return 237 | 238 | for mdblist_list in my_lists: 239 | process_list(mdblist_list) 240 | 241 | 242 | def process_hardcoded_lists(): 243 | collections = [] 244 | for section in config_parser.sections(): 245 | if section == "admin": 246 | continue 247 | try: 248 | collections.append( 249 | { 250 | "name": section, 251 | "id": config_parser.get(section, "id", fallback=None), 252 | "source": config_parser.get(section, "source", fallback=""), 253 | "poster": config_parser.get(section, "poster", fallback=None), 254 | "frequency": config_parser.get(section, "frequency", fallback=100), 255 | "mdblist_name": config_parser.get( 256 | section, "list_name", fallback=None 257 | ), 258 | "user_name": config_parser.get(section, "user_name", fallback=None), 259 | "update_items_sort_names": config_parser.getboolean( 260 | section, "update_items_sort_names", fallback=False 261 | ), 262 | "collection_sort_name": config_parser.get( 263 | section, "collection_sort_name", fallback=None 264 | ), 265 | "overwrite_description": config_parser.get( 266 | section, "description", fallback=None 267 | ), 268 | } 269 | ) 270 | except configparser.NoOptionError as e: 271 | print(f"Error in config file, section: {section}: {e}") 272 | 273 | for mdblist_list in collections: 274 | process_list(mdblist_list) 275 | 276 | 277 | def set_poster(collection_id, collection_name, poster_path=None): 278 | """ 279 | Sets the poster for a collection. Will not upload if temp config file 280 | shows that it been uploaded before. 281 | 282 | Args: 283 | collection_id (str): The ID of the collection. 284 | collection_name (str): The name of the collection. Only used for logging. 285 | poster_path (str): The path or URL to the new poster image. 286 | 287 | Returns: 288 | None 289 | """ 290 | 291 | if poster_path is None: 292 | return 293 | 294 | if poster_path == db_manager.get_config_for_section(collection_id, "poster_path"): 295 | print(f"Poster for {collection_name} is already set to the specified path.") 296 | return 297 | 298 | if emby.set_image(collection_id, poster_path): 299 | db_manager.set_config_for_section(collection_id, "poster_path", poster_path) 300 | print(f"Poster for {collection_name} has been set successfully.") 301 | else: 302 | print(f"Failed to set poster for {collection_name}.") 303 | 304 | 305 | def main(): 306 | global newly_added 307 | global newly_removed 308 | iterations = 0 309 | 310 | # print(f"Emby System Info: {emby.get_system_info()}") 311 | # print() 312 | # print(f"Emby Users: {emby.get_users()}") 313 | # print() 314 | # print(f"MDBList User Info: {mdblist.get_mdblist_user_info()}") 315 | # print() 316 | 317 | # Test getting a list via url 318 | # mdblist_list = mdblist.get_list_using_url( 319 | # "https://mdblist.com/lists/amything/best-documentaries" 320 | # ) 321 | # print(mdblist_list) 322 | # return 323 | 324 | # Test getting all Emby collections 325 | # print(emby.get_all_collections(False)) 326 | # return 327 | 328 | while True: 329 | 330 | try: 331 | response = requests.get("http://www.google.com/", timeout=5) 332 | except requests.RequestException: 333 | print("No internet connection. Check your connection. Retrying in 5 min...") 334 | time.sleep(300) 335 | continue 336 | 337 | emby_info = emby.get_system_info() 338 | if emby_info is False: 339 | print("Error connecting to Emby. Retrying in 5 min...") 340 | time.sleep(300) 341 | continue 342 | 343 | mdblist_user_info = mdblist.get_user_info() 344 | if mdblist_user_info is False: 345 | print("Error connecting to MDBList. Retrying in 5 min...") 346 | time.sleep(300) 347 | continue 348 | 349 | if download_manually_added_lists: 350 | process_hardcoded_lists() 351 | 352 | if download_my_mdblist_lists_automatically: 353 | process_my_lists_on_mdblist() 354 | 355 | print( 356 | f"\nSUMMARY: Added {newly_added} to collections and removed {newly_removed}\n" 357 | ) 358 | newly_added = 0 359 | newly_removed = 0 360 | 361 | if len(collection_ids_with_custom_sorting) > 0: 362 | print("Setting sort names for new items in collections") 363 | for collection_id in collection_ids_with_custom_sorting: 364 | item_sorting.process_collection(collection_id) 365 | 366 | print( 367 | "\n\nReverting sort names that are no longer in collections, fetching items:" 368 | ) 369 | 370 | item_sorting.reset_items_not_in_custom_sort_categories() 371 | 372 | if refresh_items is True: 373 | print( 374 | f"\nRefreshing metadata for items that were added within {refresh_items_max_days_since_added} days AND premiered within {refresh_items_max_days_since_premiered} days." 375 | ) 376 | 377 | for collection_id in all_collections_ids: 378 | if refresh_items is True: 379 | refresher.process_collection( 380 | collection_id, 381 | refresh_items_max_days_since_added, 382 | refresh_items_max_days_since_premiered, 383 | ) 384 | 385 | if hours_between_refresh == 0: 386 | break 387 | 388 | print(f"\n\nWaiting {hours_between_refresh} hours for next refresh.\n\n") 389 | time.sleep(hours_between_refresh * 3600) 390 | iterations += 1 391 | 392 | 393 | if __name__ == "__main__": 394 | main() 395 | -------------------------------------------------------------------------------- /app_backup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Run this file to backup of Watch history and Favorites for all users to text files. 3 | You can use this if you don't have access to the Emby database but you do have access to the API. 4 | """ 5 | 6 | import configparser 7 | import json 8 | from src.emby import Emby 9 | import os 10 | 11 | directory = "backup" 12 | backup_filters = ["IsPlayed", "IsFavorite"] 13 | seconds_between_requests = 10 14 | 15 | config_parser = configparser.ConfigParser() 16 | 17 | 18 | def read_config_file(parser, filename): 19 | try: 20 | with open(filename, encoding="utf-8") as f: 21 | parser.read_file(f) 22 | return True 23 | except FileNotFoundError: 24 | return False 25 | 26 | 27 | if not read_config_file(config_parser, "config_hidden.cfg"): 28 | read_config_file(config_parser, "config.cfg") 29 | 30 | emby_server_url = config_parser.get("admin", "emby_server_url") 31 | emby_user_id = config_parser.get("admin", "emby_user_id") 32 | emby_api_key = config_parser.get("admin", "emby_api_key") 33 | emby = Emby(emby_server_url, emby_user_id, emby_api_key) 34 | emby.seconds_between_requests = seconds_between_requests 35 | 36 | 37 | def get_all_items(user_id, filter): 38 | 39 | items = emby.get_items( 40 | params={"userId": user_id}, 41 | fields=["ProviderIds"], 42 | include_item_types=["Movie", "Series", "Episode"], 43 | filters=[filter], 44 | ) 45 | 46 | returned_items = [] 47 | for item in items: 48 | include_fields = [ 49 | "Id", 50 | "Name", 51 | "ProviderIds", 52 | "Type", 53 | ] 54 | # remove any fields that are not in include_fields 55 | item = {key: item[key] for key in include_fields if key in item} 56 | # remove any item["ProviderIds"] that are not relevant 57 | include_provider_ids = ["imdb", "tmdb", "tvdb"] 58 | if "ProviderIds" in item: 59 | item["ProviderIds"] = { 60 | key: value 61 | for key, value in item["ProviderIds"].items() 62 | if key.lower() in include_provider_ids 63 | } 64 | returned_items.append(item) 65 | 66 | return returned_items 67 | 68 | 69 | def main(): 70 | all_users = emby.get_users() 71 | 72 | backup_user_names = config_parser.get("admin", "backup_user_names", fallback=None) 73 | if backup_user_names is not None: 74 | backup_user_names = backup_user_names.split(",") 75 | all_users = [user for user in all_users if user["Name"] in backup_user_names] 76 | 77 | if not os.path.exists(directory): 78 | os.makedirs(directory) 79 | 80 | print(f"\nBacking up Watch history and Favorites for {len(all_users)} users\n") 81 | 82 | for filter in backup_filters: 83 | for user in all_users: 84 | user_id = user["Id"] 85 | user_name = user["Name"] 86 | data = { 87 | "user_name": user_name, 88 | "user_id": user_id, 89 | "filter": filter, 90 | "Items": [], 91 | } 92 | print(f"\nGetting {filter} items for {user_name}") 93 | data["Items"] = get_all_items(user_id, filter) 94 | file_name = f"{directory}/{filter}_{user_name}.json" 95 | print(f"\nWriting {len(data['Items'])} items to {file_name}") 96 | with open(file_name, "w", encoding="utf-8") as f: 97 | json.dump(data, f, indent=4) 98 | 99 | 100 | if __name__ == "__main__": 101 | main() 102 | -------------------------------------------------------------------------------- /app_restore_backup.py: -------------------------------------------------------------------------------- 1 | """ 2 | This script can restore a backup to another Emby server from previously using app_backup.py. 3 | 4 | The script assumes that the new Emby server does not have the same item ID as the old Emby server and 5 | will lookup all items by IMDB or TVDB ID. 6 | 7 | Execute the script from the command line with the required arguments: 8 | 9 | --host: The Emby server host. 10 | --user_id: The user ID for the Emby server (ID string, not the user name). 11 | --api_key: The API key for accessing the Emby server. 12 | --source_file: The path to the backup file. 13 | You can use this if you don't have access to the Emby database but you do have access to the API. 14 | Example command: 15 | 16 | python3 app_restore_backup.py -host http://xxx.xxx.xxx.xxx:xxxx -user_id abc123 -api_key abc123 -source_file "backup\IsPlayed_SomeUsername.json" 17 | 18 | Json file exmple: 19 | { 20 | "user_name": "Jason", 21 | "user_id": "abc123", 22 | "filter": "IsPlayed", // or "IsFavorite" 23 | "Items": [ 24 | { 25 | "Id": "15172942", 26 | "Name": "The Matrix", 27 | "ProviderIds": { 28 | "Imdb": "tt21215388", 29 | "Tmdb": "1090446", 30 | "Tvdb": "357485" 31 | }, 32 | "Type": "Movie" 33 | }, 34 | etc 35 | """ 36 | 37 | from src.emby import Emby 38 | import ast 39 | import sys 40 | from argparse import ArgumentParser 41 | 42 | backup_filters = ["IsPlayed", "IsFavorite"] 43 | 44 | 45 | def get_provider_id(provider_ids, key): 46 | # Normalize keys to lowercase since some providers return keys in different cases 47 | normalized_dict = {k.lower(): v for k, v in provider_ids.items()} 48 | return normalized_dict.get(key.lower()) 49 | 50 | 51 | def add_error(error): 52 | print(f"\nERROR: {error}") 53 | 54 | 55 | def main(args): 56 | emby = Emby(args.host, args.user_id, args.api_key) 57 | emby.seconds_between_requests = 0.1 58 | backup_file = args.source_file 59 | 60 | with open(backup_file, "r") as file: 61 | backup_data = file.read() 62 | 63 | filter = ast.literal_eval(backup_data)["filter"] 64 | items = ast.literal_eval(backup_data)["Items"] 65 | user_id = args.user_id 66 | 67 | for item in items: 68 | 69 | if "Id" not in item or "Name" not in item: 70 | add_error(f"{item} is missing Id or Name") 71 | continue 72 | 73 | item_id = int(item["Id"]) 74 | new_item_ids = None 75 | item_name = item["Name"] 76 | item_type = item["Type"] 77 | 78 | imdb_id = None 79 | tvdb_id = None 80 | 81 | provider_ids = item.get("ProviderIds", None) 82 | 83 | if provider_ids is None: 84 | add_error(f"{item_id}:{item_name} is missing ProviderIds") 85 | continue 86 | 87 | imdb_id = get_provider_id(provider_ids, "imdb") 88 | tvdb_id = get_provider_id(provider_ids, "tvdb") 89 | 90 | if imdb_id is None and tvdb_id is None: 91 | add_error(f"Can not find IMDB or TVDB ID for: {item_id}: {item_name}") 92 | continue 93 | 94 | if item_type not in ["Series", "Episode", "Movie"]: 95 | add_error(f"{item_id}:{item_name} has invalid type {item_type}") 96 | continue 97 | 98 | if item_type == "Episode": 99 | 100 | if tvdb_id is None: 101 | add_error(f"Can not find TVDB ID for episode {item_id}: {item_name}") 102 | continue 103 | 104 | new_item_ids = emby.get_items_with_tvdb_id([tvdb_id], [item_type]) 105 | 106 | elif item_type == "Series" or item_type == "Movie": 107 | 108 | if imdb_id is not None: # Prefer IMDB first I guess 109 | new_item_ids = emby.get_items_with_imdb_id([imdb_id], [item_type]) 110 | elif tvdb_id is not None: 111 | new_item_ids = emby.get_items_with_tvdb_id([tvdb_id], [item_type]) 112 | 113 | if new_item_ids is None or new_item_ids == []: 114 | add_error( 115 | f"Can not find new Item for TVDB: {tvdb_id} IMDB: {imdb_id} Name: {item_name}. " 116 | ) 117 | continue 118 | 119 | for new_item_id in new_item_ids: 120 | if filter == "IsPlayed": 121 | set_as_played = emby.set_item_as_played(user_id, new_item_id) 122 | if not set_as_played: 123 | add_error(f"Cannot set to IsPlayed {new_item_id}: {item_name}") 124 | elif filter == "IsFavorite": 125 | set_as_favorite = emby.set_item_as_favorite(user_id, new_item_id) 126 | if not set_as_favorite: 127 | add_error(f"Cannot set to IsFavorite {item_id}: {item_name}") 128 | 129 | 130 | if __name__ == "__main__": 131 | 132 | parser = ArgumentParser() 133 | 134 | if len(sys.argv) == 1: 135 | parser.print_help() 136 | quit() 137 | 138 | parser.add_argument("-host", dest="host", help="Destination Emby host") 139 | parser.add_argument("-user_id", dest="user_id", help="Destination user ID") 140 | parser.add_argument( 141 | "-api_key", 142 | dest="api_key", 143 | help="Destination Emby API key", 144 | ) 145 | parser.add_argument( 146 | "-source_file", 147 | dest="source_file", 148 | help="Source file to restore from", 149 | ) 150 | args = parser.parse_args() 151 | main(args) 152 | -------------------------------------------------------------------------------- /config.cfg: -------------------------------------------------------------------------------- 1 | # If config_hidden.cfg exists it will be used instead of this file. 2 | # Just copy paste the contents of this file into config_hidden.cfg 3 | # and make your changes there. When updating the script, you can safely 4 | # overwrite this file without losing your settings. 5 | 6 | [admin] 7 | # Emby server URL 8 | emby_server_url = https://example.com:8096 9 | 10 | # User ID of an Emby admin user, NOT user name. 11 | # To find this ID, navigate to "Manage Emby Server" > "Users", 12 | # click on an admin user, and grab the userID from the URL. 13 | emby_user_id = abc123 14 | 15 | # Emby API key. 16 | # To find this key, go to "Manage Emby Server" > "Advanced" > "API Keys" 17 | # and click on "New API Key" 18 | emby_api_key = abc123 19 | 20 | # MDBList API key. 21 | # Create an account on https://mdblist.com/, scroll to the bottom of 22 | # https://mdblist.com/preferences/ to "API Access", and generate an API key 23 | mdblist_api_key = abc123 24 | 25 | # Set to True to enable downloading of lists added manually below 26 | download_manually_added_lists = True 27 | 28 | # Set to True to enable automatic downloading of your lists from https://mdblist.com/mylists/ 29 | download_my_mdblist_lists_automatically = True 30 | 31 | # Set to True to use the description of the collection from MDBList.com in Emby 32 | # You can overwrite this description by setting a description below for each collection. 33 | use_mdblist_collection_description = True 34 | 35 | # Change Sort Name of Collections so that those that get modified are at the top 36 | update_collection_sort_name = True 37 | 38 | # Set the frequency of script execution (in hours). Set to 0 to disable repetition 39 | hours_between_refresh = 6 40 | 41 | # Set to True to update the sort names of TV Shows and Movies so that 42 | # they are sorted by time added to Emby. This is useful if you want to see the latest 43 | # added items first in the collection. You can change this value below for collections 44 | # individually. This value will also affect "My Lists" on MDBList.com. See Readme. 45 | update_items_sort_names_default_value = False 46 | 47 | # Set to True to refresh items that are in collections. Helps to keep the ratings of 48 | # new movies and shows up to date until the rating settles a bit on IMDB etc. 49 | # Also specify the maximum number of days since the item was added to Emby and since 50 | # it premiered. Both max_days_since_added and max_days_since_premiered must be satisfied 51 | # for an item to be refreshed. This is to prevent refreshing items that were added a 52 | # short time ago but premiered a long time ago since those items will have 53 | # accurate ratings already. 54 | refresh_items_in_collections = True 55 | refresh_items_in_collections_max_days_since_added = 10 56 | refresh_items_in_collections_max_days_since_premiered = 30 57 | 58 | # If you use the backup script, you can comma seperate the user names of the users you want to backup. 59 | # Leave out to backup all users. 60 | # backup_user_names = john,yoko 61 | 62 | ############################################################################# 63 | ########################## ADD YOUR MDBLists BELOW ########################## 64 | ############################################################################# 65 | # You can find public lists at https://mdblist.com/toplists/ 66 | # Note: These are random users' lists that might disappear at any time. 67 | # To ensure longevity, clone lists on MDBList to your own My Lists. 68 | 69 | # COLLECTION TITLE will be the string within the brackets. 70 | 71 | # DESCRIPTION is optional. Set a description for the collection that shows up in Emby. 72 | # Overwrites the description from MDBList.com if any. 73 | 74 | # SOURCE is the URL to the list you want to add. You can have more than 1 for the 75 | # same collection, just separate them with a comma. 76 | 77 | # FREQUENCY is optional. If it's set to 100 it will be downloaded 100% of the 78 | # time. If it's 50% it will be randomly updated 50% of the time etc. 79 | 80 | # POSTER is optional. You can use a local path or an image URL. You can find 81 | # posters images at https://plexcollectionposters.com/ for example. 82 | 83 | # UPDATE_ITEMS_SORT_NAMES is optional. If set to True it will sort the items in the 84 | # collection by the time they were added to Emby. This is useful if you want to see 85 | # the latest added items first in the collection. 86 | 87 | # ACTIVE_BETWEEN is optional. It's used to show a collection only during a specific 88 | # period like Halloween or Christmas. The formatting is year-month-day. But usually 89 | # you want to skip the year. Example: 90 | # active_between = 09-30, 11-01 which is September 30th to November 1st. 91 | # If you include the year it will only show that collection that year. 92 | 93 | # COLLECTION_SORT_NAME is optiona. Set a custom sort name for a collection. 94 | 95 | # Use the examples below to add your own lists. Refer to README.md for more information. 96 | [Trending TV Shows] 97 | source = https://mdblist.com/lists/teddysmoot/trending-new-shows 98 | frequency = 100 99 | update_items_sort_names = False 100 | description = The latest trending TV shows. 101 | 102 | # You can add multiple sources to a list by separating them with a comma. 103 | # This examples adds both trending kids movies and TV shows to the same collection 104 | [Kids Trending] 105 | source = https://mdblist.com/lists/tvgeniekodi/trending-kids-movies, https://mdblist.com/lists/japante/trending-kids-shows 106 | frequency = 100 107 | update_items_sort_names = False 108 | description = The latest trending kids movies and TV shows. 109 | 110 | # Example using a collection poster. Either a local path or an image URL. 111 | # https://theposterdb.com/ and https://plexcollectionposters.com/ have a lot of great posters. 112 | # Local paths can contain spaces and not enclosed by quotes. 113 | [Oscars 2024] 114 | source = https://mdblist.com/lists/squint/the-96th-academy-awards 115 | poster = https://plexcollectionposters.com/images/2019/01/31/oscars9a7c2bc47188f883.png 116 | frequency = 100 117 | update_items_sort_names = False 118 | description = All the movies that were nominated for the 96th Academy Awards. 119 | # Example of using a custom sort name for a collection. 120 | collection_sort_name = zzzz I want this collection to show up last 121 | 122 | # Example using a list that should only show during a specific period like Halloween. 123 | # Example: active_between = 2024-09-30, 2024-12-01 124 | # Where first number sequence is date from and second is date to. 125 | # Usually you want to skip the year so that it shows yearly. 126 | # Example: active_between = 09-30, 11-01. 127 | [Best of Halloween] 128 | source = https://mdblist.com/lists/hdlists/the-top-100-halloween-movies-of-all-time 129 | poster = https://theposterdb.com/api/assets/190058/view 130 | active_between = 09-30, 11-01 131 | frequency = 100 132 | update_items_sort_names = False -------------------------------------------------------------------------------- /images/ACdb_Official_Collections.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonjonsson/Emby-MDBList-Collection-Creator/7ee930b07c9bb60abf111d2b05011bbb4ec15bef/images/ACdb_Official_Collections.png -------------------------------------------------------------------------------- /images/ACdb_collection_settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonjonsson/Emby-MDBList-Collection-Creator/7ee930b07c9bb60abf111d2b05011bbb4ec15bef/images/ACdb_collection_settings.png -------------------------------------------------------------------------------- /images/ACdb_missing_items.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonjonsson/Emby-MDBList-Collection-Creator/7ee930b07c9bb60abf111d2b05011bbb4ec15bef/images/ACdb_missing_items.png -------------------------------------------------------------------------------- /images/ACdb_plugin_install.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonjonsson/Emby-MDBList-Collection-Creator/7ee930b07c9bb60abf111d2b05011bbb4ec15bef/images/ACdb_plugin_install.png -------------------------------------------------------------------------------- /images/ACdb_seasonal_collections.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonjonsson/Emby-MDBList-Collection-Creator/7ee930b07c9bb60abf111d2b05011bbb4ec15bef/images/ACdb_seasonal_collections.png -------------------------------------------------------------------------------- /images/banner.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonjonsson/Emby-MDBList-Collection-Creator/7ee930b07c9bb60abf111d2b05011bbb4ec15bef/images/banner.jpeg -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests -------------------------------------------------------------------------------- /src/date_parser.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, date 2 | 3 | 4 | def get_appropriate_year(month, day, reference_date): 5 | test_date = date(reference_date.year, month, day) 6 | if test_date <= reference_date: 7 | return reference_date.year 8 | return reference_date.year - 1 9 | 10 | 11 | def inside_period(active_period_str): 12 | """ 13 | Parse a string representing an active period and determine if the current date falls within it. 14 | 15 | This function accepts a string containing two dates separated by a comma. Each date 16 | can be in either 'YYYY-MM-DD' or 'MM-DD' format. When the year is omitted (MM-DD format): 17 | - For the start date: It assumes the most recent occurrence of that month-day in the past. 18 | - For the end date: It assumes the next occurrence after the start date. 19 | 20 | The function then checks if the current date is within the parsed date range. 21 | 22 | Parameters: 23 | active_period_str (str): A string in the format "start_date, end_date" where each date 24 | is either "YYYY-MM-DD" or "MM-DD". 25 | 26 | Returns: 27 | bool: True if the current date is within the parsed date range, False otherwise. 28 | Returns False if parsing fails. 29 | 30 | Examples: 31 | - "2023-09-30, 2023-11-01" -> True if current date is between Sep 30, 2023 and Nov 1, 2023 32 | - "09-30, 11-01" -> Assumes years based on the current date 33 | - "2023-09-30, 11-01" -> Interprets end date as Nov 1 of the appropriate year 34 | 35 | Note: 36 | - The function uses the current date both for reference when parsing dates without years 37 | and for determining if the current date is within the active period. 38 | - If parsing fails for any reason, the function returns False. 39 | """ 40 | 41 | try: 42 | start_date_str, end_date_str = map(str.strip, active_period_str.split(",")) 43 | 44 | today = date.today() 45 | 46 | # Parse start date 47 | if len(start_date_str) == 10: # Full date string (YYYY-MM-DD) 48 | start_date = datetime.strptime(start_date_str, "%Y-%m-%d").date() 49 | elif len(start_date_str) == 5: # Short date string (MM-DD) 50 | month, day = map(int, start_date_str.split("-")) 51 | year = get_appropriate_year(month, day, today) 52 | start_date = date(year, month, day) 53 | else: 54 | raise ValueError("Invalid date format") 55 | 56 | # Parse end date 57 | if len(end_date_str) == 10: # Full date string (YYYY-MM-DD) 58 | end_date = datetime.strptime(end_date_str, "%Y-%m-%d").date() 59 | elif len(end_date_str) == 5: # Short date string (MM-DD) 60 | month, day = map(int, end_date_str.split("-")) 61 | year = get_appropriate_year(month, day, start_date) 62 | end_date = date(year, month, day) 63 | if end_date <= start_date: 64 | end_date = date(year + 1, month, day) 65 | else: 66 | raise ValueError("Invalid date format") 67 | 68 | # Check if today is within the parsed date range 69 | return start_date <= today <= end_date 70 | 71 | except (ValueError, TypeError): 72 | return False 73 | 74 | 75 | def main(): 76 | # Example usage 77 | test_cases = [ 78 | "2023-09-30, 2023-11-01", 79 | "09-30, 11-01", 80 | "09-30, 01-02", 81 | "2023-09-30, 11-01", 82 | "12-01, 02-28", # Spans across new year 83 | "invalid date", # Invalid input 84 | ] 85 | 86 | for case in test_cases: 87 | result = inside_period(case) 88 | print(f"Active period: {case}") 89 | print(f"Current date is within range: {result}") 90 | print("-" * 30) 91 | 92 | 93 | if __name__ == "__main__": 94 | main() 95 | -------------------------------------------------------------------------------- /src/db.py: -------------------------------------------------------------------------------- 1 | import os 2 | import configparser 3 | 4 | 5 | class Db: 6 | """ 7 | Simple db using a configuration file to store and retrieve values. 8 | """ 9 | 10 | def __init__(self, temp_dir="temp", config_file_name="db.cfg"): 11 | self.temp_dir = temp_dir 12 | self.config_file = os.path.join(self.temp_dir, config_file_name) 13 | self.config, _ = self.ensure_db_config() 14 | 15 | def ensure_db_config(self): 16 | """ 17 | Ensures that the db.cfg file and temp directory exist. 18 | 19 | Returns: 20 | configparser.ConfigParser: The configuration parser object. 21 | """ 22 | if not os.path.exists(self.temp_dir): 23 | os.makedirs(self.temp_dir) 24 | 25 | config = configparser.ConfigParser() 26 | 27 | if os.path.exists(self.config_file): 28 | config.read(self.config_file) 29 | 30 | return config, self.config_file 31 | 32 | def get_config_for_section(self, section, param_name): 33 | """ 34 | Args: 35 | section (str): The name of the section. 36 | param_name (str): The name of the parameter to retrieve. 37 | 38 | Returns: 39 | str: The value of the specified parameter for the section, or None if not found. 40 | """ 41 | section = str(section) 42 | if section in self.config.sections(): 43 | return self.config.get(section, param_name, fallback=None) 44 | return None 45 | 46 | def set_config_for_section(self, section, param_name, param_value): 47 | """ 48 | Sets the configuration for a collection. 49 | 50 | Args: 51 | section (str): The name of the section. 52 | param_name (str): The name of the parameter to set. 53 | param_value (str): The value of the parameter to set. 54 | """ 55 | section = str(section) 56 | if section not in self.config.sections(): 57 | self.config.add_section(section) 58 | self.config.set(section, param_name, param_value) 59 | 60 | with open(self.config_file, "w") as configfile: 61 | self.config.write(configfile) 62 | -------------------------------------------------------------------------------- /src/emby.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | import requests 4 | import base64 5 | from urllib.parse import quote 6 | 7 | ## Helpful URLS for dev: 8 | # https://swagger.emby.media/?staticview=true#/ 9 | # https://github.com/MediaBrowser/Emby/wiki 10 | # https://dev.emby.media/doc/restapi/Browsing-the-Library.html 11 | # https://docs.mdblist.com/docs/api 12 | 13 | 14 | class Emby: 15 | 16 | def __init__(self, server_url, user_id, api_key): 17 | self.server_url = server_url 18 | self.user_id = user_id 19 | self.api_key = api_key 20 | self.headers = {"X-MediaBrowser-Token": api_key} 21 | # To prevent too long URLs, queries are done in batches of n 22 | self.api_batch_size = 50 23 | self.seconds_between_requests = 1 24 | # get system info to see if it works 25 | self.system_info = self.get_system_info() 26 | 27 | def get_system_info(self): 28 | endpoint = "/emby/System/Info" 29 | url = self.server_url + endpoint 30 | try: 31 | response = requests.get(url, headers=self.headers) 32 | return response.json() 33 | except Exception as e: 34 | print( 35 | f"Error occurred while getting Emby system info, check your configuration. Check your Emby url and port, user ID and API key: {e}" 36 | ) 37 | return False 38 | 39 | def get_users(self): 40 | user_list_endpoint = "/emby/Users" 41 | user_list_url = self.server_url + user_list_endpoint 42 | user_list_response = requests.get(user_list_url, headers=self.headers) 43 | try: 44 | return user_list_response.json() 45 | except Exception as e: 46 | print(f"Error occurred while getting users: {e}") 47 | return None 48 | 49 | def get_items_starting_with_sort_name(self, filter, limit=20): 50 | """ 51 | Retrieves all movies and series whose SortName starts with the specified filter. 52 | Must be queried like this because it's not possible to search for SortName directly. 53 | 54 | Args: 55 | filter (str): The filter to match the SortName against. 56 | 57 | Returns: 58 | list: A list of items (movies and series) whose SortName starts with the filter. 59 | """ 60 | limit = 50 61 | start_index = 0 62 | filtered_items = [] 63 | found_sort_name = True 64 | 65 | while found_sort_name: 66 | 67 | items = self.get_items( 68 | fields=["SortName"], 69 | include_item_types=["Movie", "Series"], 70 | sort_by="SortName", 71 | limit=limit, 72 | start_index=start_index, 73 | getAll=False, 74 | ) 75 | 76 | for item in items: 77 | if item["SortName"].startswith(filter): 78 | filtered_items.append(item) 79 | else: 80 | found_sort_name = False 81 | break 82 | 83 | time.sleep(self.seconds_between_requests) 84 | start_index += limit 85 | 86 | return filtered_items 87 | 88 | def get_items_with_imdb_id(self, imdb_ids, item_types=None): 89 | batch_size = self.api_batch_size 90 | returned_items = [] 91 | gotten_item_names = [] 92 | 93 | if item_types is None: 94 | item_types = ["Movie", "Series"] 95 | else: 96 | item_types = [ 97 | ( 98 | "Series" 99 | if item_type.lower() in ["tv", "show"] 100 | else "Movie" if item_type.lower() == "movie" else item_type 101 | ) 102 | for item_type in item_types 103 | ] 104 | 105 | for i in range(0, len(imdb_ids), batch_size): 106 | batch_imdb_ids = imdb_ids[i : i + batch_size] 107 | # Remove any ids from batch_imdb_ids that are None 108 | batch_imdb_ids = [ 109 | imdb_id for imdb_id in batch_imdb_ids if imdb_id is not None 110 | ] 111 | imdb_ids_str = ",".join(["imdb." + imdb_id for imdb_id in batch_imdb_ids]) 112 | 113 | items = self.get_items( 114 | params={"AnyProviderIdEquals": imdb_ids_str}, 115 | fields=["ChildCount", "RecursiveItemCount"], 116 | include_item_types=item_types, 117 | limit=batch_size, 118 | ) 119 | 120 | for item in items: 121 | if item["Name"] not in gotten_item_names: 122 | returned_items.append(item["Id"]) 123 | gotten_item_names.append(item["Name"]) 124 | 125 | return returned_items 126 | 127 | def get_items_with_tvdb_id(self, tvdb_ids, item_types=None): 128 | batch_size = self.api_batch_size 129 | returned_items = [] 130 | gotten_item_names = [] 131 | 132 | if item_types is None: 133 | item_types = ["Movie", "Series", "Episode"] 134 | else: 135 | item_types = [ 136 | ( 137 | "Series" 138 | if item_type.lower() in ["tv", "show"] 139 | else ( 140 | "Movie" 141 | if item_type.lower() == "movie" 142 | else "Episode" if item_type.lower() == "episode" else item_type 143 | ) 144 | ) 145 | for item_type in item_types 146 | ] 147 | 148 | for i in range(0, len(tvdb_ids), batch_size): 149 | batch_tvdb_ids = tvdb_ids[i : i + batch_size] 150 | tvdb_ids_str = ",".join(["tvdb." + tvdb_id for tvdb_id in batch_tvdb_ids]) 151 | 152 | items = self.get_items( 153 | params={"AnyProviderIdEquals": tvdb_ids_str}, 154 | fields=["ChildCount", "RecursiveItemCount"], 155 | include_item_types=item_types, 156 | limit=batch_size, 157 | ) 158 | 159 | for item in items: 160 | if item["Name"] not in gotten_item_names: 161 | returned_items.append(item["Id"]) 162 | gotten_item_names.append(item["Name"]) 163 | 164 | return returned_items 165 | 166 | def get_all_collections(self, include_contents=True): 167 | """ 168 | Retrieves all collections from the Emby server. 169 | 170 | Parameters: 171 | - include_contents (bool): Flag to indicate whether to include the items within each collection. 172 | 173 | Returns: 174 | - collections_list (list): List of dictionaries representing each collection, including its name, ID, and items (if include_contents is True). 175 | """ 176 | endpoint = f"/emby/users/{self.user_id}/items?Fields=ChildCount,RecursiveItemCount&Recursive=true&IncludeItemTypes=boxset" 177 | url = self.server_url + endpoint 178 | response = requests.get(url, headers=self.headers) 179 | try: 180 | items = response.json() 181 | except Exception as e: 182 | print( 183 | f"Error occurred while getting collections using url {url}: {e}. Response: {response}" 184 | ) 185 | return None 186 | 187 | collections_list = [] 188 | 189 | for item in items["Items"]: 190 | items_in_collection = None 191 | if include_contents: 192 | items_in_collection = self.get_items_in_collection( 193 | item["Id"], ["ProviderIds"] 194 | ) 195 | collections_list.append( 196 | {"Name": item["Name"], "Id": item["Id"], "items": items_in_collection} 197 | ) 198 | 199 | return collections_list 200 | 201 | def get_items_in_collection(self, collection_id: int, fields: list = None): 202 | """ 203 | Retrieves items in a collection based on the provided collection ID. 204 | 205 | Args: 206 | collection_id (str or int): The ID of the collection. 207 | fields (list): List of fields to include in the response. Defaults to None. 208 | 209 | Returns: 210 | list: A list of dictionaries containing the structured items in the collection. 211 | Each dictionary contains the specified fields for each item. 212 | """ 213 | if collection_id is None: 214 | return None 215 | endpoint = f"/emby/users/{self.user_id}/items?Parentid={collection_id}" 216 | if fields: 217 | fields_str = ",".join(fields) 218 | endpoint += f"&Fields={fields_str}" 219 | url = self.server_url + endpoint 220 | response = requests.get(url, headers=self.headers) 221 | 222 | try: 223 | items = response.json() 224 | except Exception as e: 225 | print( 226 | f"Error occurred while getting items in collection id {collection_id} using url {url} response was {response}: {e}" 227 | ) 228 | return None 229 | 230 | structured_items = [] 231 | for item in items["Items"]: 232 | add_item = { 233 | "Id": item["Id"], 234 | "Name": item["Name"], 235 | "Type": item["Type"], 236 | } 237 | if "ProviderIds" in item: 238 | # Need special treatment because "Imdb" is sometimes all caps and sometimes not! 239 | imdb_id = item["ProviderIds"].get("Imdb") or item["ProviderIds"].get( 240 | "IMDB" 241 | ) 242 | add_item["Imdb"] = imdb_id 243 | 244 | for field in fields: 245 | add_item[field] = item.get(field) 246 | 247 | structured_items.append(add_item) 248 | return structured_items 249 | 250 | def create_collection(self, collection_name, item_ids) -> bool: 251 | """ 252 | Check if collection exists, creates if not, then adds items to it. 253 | Must add at least one item when creating collection. 254 | 255 | Args: 256 | collection_name (str): The name of the collection to be created. 257 | item_ids (list): A list of item IDs to be added to the collection. 258 | 259 | Returns: 260 | bool: True if the collection is created successfully, False otherwise. 261 | """ 262 | if not item_ids: 263 | print("Can't create collection, no items to add to it.") 264 | return None 265 | 266 | response = requests.post( 267 | f"{self.server_url}/Collections?api_key={self.api_key}&IsLocked=true&Name={quote(collection_name)}&Ids={self.__ids_to_str(item_ids)}" 268 | ) 269 | 270 | if response.status_code != 200: 271 | print(f"Error creating {collection_name}, response: {response}") 272 | return None 273 | 274 | print(f"Successfully created collection {collection_name}") 275 | return response.json()["Id"] 276 | 277 | # Not tested and not working for collections. 278 | def delete_item(self, item_id) -> bool: 279 | """ 280 | Deletes an item from the Emby server. 281 | 282 | Args: 283 | item_id (str): The ID of the item to be deleted. 284 | 285 | Returns: 286 | bool: True if the item is deleted successfully, False otherwise. 287 | """ 288 | 289 | url = f"{self.server_url}/Items?{item_id}&api_key={self.api_key}" 290 | response = requests.delete(url) 291 | if response.status_code == 204: 292 | return True 293 | else: 294 | return False 295 | 296 | def get_item(self, item_id) -> dict: 297 | endpoint = f"/emby/users/{self.user_id}/items/{item_id}" 298 | url = self.server_url + endpoint 299 | try: 300 | return requests.get(url, headers=self.headers).json() 301 | except Exception as e: 302 | print(f"Error occurred while getting item: {e}. URL: {url}.") 303 | return None 304 | 305 | def set_item_property(self, item_id, property_name, property_value): 306 | return self.__update_item(item_id, {property_name: property_value}) 307 | 308 | def get_collection_id(self, collection_name): 309 | all_collections = self.get_all_collections(False) 310 | collection_found = False 311 | 312 | for collection in all_collections: 313 | if collection_name == collection["Name"]: 314 | collection_found = True 315 | collection_id = collection["Id"] 316 | break 317 | 318 | if collection_found is False: 319 | return None 320 | 321 | return collection_id 322 | 323 | def add_to_collection(self, collection_name, item_ids: list) -> int: 324 | # Returns the number of items added to the collection 325 | return self.__add_remove_from_collection(collection_name, item_ids, "add") 326 | 327 | def delete_from_collection(self, collection_name, item_ids: list) -> int: 328 | # Returns the number of items deleted from the collection 329 | return self.__add_remove_from_collection(collection_name, item_ids, "delete") 330 | 331 | def refresh_item(self, item_id): 332 | # Refreshes metadata for a specific item 333 | response = requests.post( 334 | f"{self.server_url}/Items/{item_id}/Refresh?api_key={self.api_key}&ReplaceAllMetadata=true" 335 | ) 336 | time.sleep(self.seconds_between_requests) 337 | if response.status_code != 204: 338 | print(f"Error refreshing item {item_id}, response: {response}") 339 | return False 340 | return True 341 | 342 | def get_items( 343 | self, 344 | params=None, 345 | fields=None, 346 | include_item_types=None, 347 | filters=None, 348 | sort_by=None, 349 | limit=50, 350 | start_index=0, 351 | getAll=True, 352 | ): 353 | """ 354 | Generic method to retrieve all items from Emby, querying in batches. 355 | 356 | Args: 357 | params (dict): Additional parameters to include in the query. 358 | fields (list): List of fields to include in the response. 359 | include_item_types (list): Types of items to include (e.g., ['Movie', 'Series']). 360 | filters (list): Filters to apply to the query. 361 | sort_by (str): Field to sort the results by. 362 | limit (int): Number of items to query in each batch. 363 | start_index (int): Index to start querying from. 364 | getAll (bool): Flag to indicate whether to retrieve all items or just the first batch. 365 | 366 | Returns: 367 | list: All items retrieved from the Emby API. 368 | """ 369 | endpoint = f"/emby/users/{self.user_id}/items" 370 | query_params = {} 371 | 372 | if params: 373 | query_params.update(params) 374 | if fields: 375 | query_params["Fields"] = ",".join(fields) 376 | if include_item_types: 377 | query_params["IncludeItemTypes"] = ",".join(include_item_types) 378 | if filters: 379 | query_params["Filters"] = ",".join(filters) 380 | if sort_by: 381 | query_params["SortBy"] = sort_by 382 | 383 | query_params["Recursive"] = "true" 384 | query_params["Limit"] = limit 385 | 386 | url = self.server_url + endpoint 387 | all_items = [] 388 | 389 | while True: 390 | print(".", end="", flush=True) 391 | time.sleep(self.seconds_between_requests) 392 | query_params["StartIndex"] = start_index 393 | response = requests.get(url, headers=self.headers, params=query_params) 394 | 395 | try: 396 | response_data = response.json() 397 | except Exception as e: 398 | print( 399 | f"Error getting items using URL {url} params {query_params} with response {response.content}. Error: {e}" 400 | ) 401 | return None 402 | 403 | if "Items" in response_data: 404 | items = response_data["Items"] 405 | all_items.extend(items) 406 | if len(items) < limit: 407 | break # We've retrieved all items 408 | start_index += limit 409 | else: 410 | break # No more items to retrieve 411 | 412 | if not getAll: 413 | break 414 | 415 | return all_items 416 | 417 | def set_item_as_played(self, user_id, item_id): 418 | """ 419 | Set an item as played for a specific user. 420 | 421 | Args: 422 | user_id (str): The ID of the user. 423 | item_id (str): The ID of the item to mark as played. 424 | 425 | Returns: 426 | bool: True if the item was marked as played successfully, False otherwise. 427 | """ 428 | endpoint = f"/emby/Users/{user_id}/PlayedItems/{item_id}" 429 | url = self.server_url + endpoint 430 | response = requests.post(url, headers=self.headers) 431 | if response.status_code == 200: 432 | return True 433 | else: 434 | print( 435 | f"Error marking item {item_id} as played for user {user_id}: {response.content}" 436 | ) 437 | return False 438 | 439 | def set_item_as_favorite(self, user_id, item_id): 440 | """ 441 | Set an item as a favorite for a specific user. 442 | 443 | Args: 444 | user_id (str): The ID of the user. 445 | item_id (str): The ID of the item to mark as a favorite. 446 | 447 | Returns: 448 | bool: True if the item was marked as a favorite successfully, False otherwise. 449 | """ 450 | endpoint = f"/emby/Users/{user_id}/FavoriteItems/{item_id}" 451 | url = self.server_url + endpoint 452 | response = requests.post(url, headers=self.headers) 453 | if response.status_code == 200: 454 | return True 455 | else: 456 | print( 457 | f"Error marking item {item_id} as a favorite for user {user_id}: {response.content}" 458 | ) 459 | return False 460 | 461 | def set_image( 462 | self, 463 | item_id, 464 | image_path, 465 | image_type="Primary", 466 | provider_name="MDBList Collection Creator script", 467 | ): 468 | """ 469 | Can take local or remote path and set as image for item. 470 | 471 | Args: 472 | item_id (str): The ID of the item. 473 | image_path (str): The path to the image. Either local or remote. 474 | image_type (str): The type of the image. Defaults to "Primary". 475 | provider_name (str): The name of the image provider. Defaults to "MDBList Collection Creator script". 476 | 477 | Returns: 478 | bool: True if the image is uploaded successfully, False otherwise. 479 | """ 480 | if image_path.startswith("http"): 481 | return self.__set_remote_image( 482 | item_id, image_path, image_type, provider_name 483 | ) 484 | else: 485 | return self.__upload_image(item_id, image_path, image_type) 486 | 487 | def __set_remote_image( 488 | self, 489 | item_id, 490 | image_url, 491 | image_type="Primary", 492 | provider_name="MDBList Collection Creator script", 493 | ): 494 | """ 495 | Downloads a remote image for an item. 496 | 497 | Args: 498 | item_id (str): The ID of the item. 499 | image_url (str): The URL of the image to download. 500 | image_type (str): The type of the image. Defaults to "Primary". 501 | provider_name (str): The name of the image provider. Defaults to "MDBList Collection Creator script". 502 | 503 | Returns: 504 | bool: True if the image is downloaded successfully, False otherwise. 505 | """ 506 | endpoint = f"/emby/Items/{item_id}/RemoteImages/Download" 507 | url = self.server_url + endpoint 508 | 509 | params = { 510 | "Type": image_type, 511 | "ProviderName": provider_name, 512 | "ImageUrl": image_url, 513 | } 514 | 515 | try: 516 | response = requests.post( 517 | url, 518 | headers=self.headers, 519 | json=params, 520 | ) 521 | 522 | if response.status_code == 204: 523 | return True 524 | else: 525 | print(f"Error setting image for item {item_id}, response: {response}") 526 | return False 527 | 528 | except Exception as e: 529 | print(f"Exception occurred while downloading image: {str(e)}") 530 | return False 531 | 532 | def __upload_image(self, item_id, image_path, image_type="Primary"): 533 | """ 534 | Uploads a poster image to a collection. Allows for .jpg, .jpeg, .png, and .tbn formats. 535 | 536 | Args: 537 | item_id (str): The ID of the item. 538 | image_path (str): The path to the image to upload. 539 | image_type (str): The type of the image. Defaults to "Primary". 540 | 541 | Returns: 542 | bool: True if the image is uploaded successfully, False otherwise. 543 | """ 544 | 545 | if not os.path.exists(image_path): 546 | print(f"Error: Image file not found: {image_path}") 547 | return False 548 | 549 | allowed_types = [".jpg", ".jpeg", ".png", ".tbn"] 550 | if not any(image_path.lower().endswith(ext) for ext in allowed_types): 551 | print( 552 | f"Unsupported image format. Must be one of: {', '.join(allowed_types)}" 553 | ) 554 | return False 555 | 556 | try: 557 | with open(image_path, "rb") as f: 558 | image_data = f.read() 559 | 560 | image_data_base64 = base64.b64encode(image_data) 561 | 562 | endpoint = ( 563 | f"emby/Items/{item_id}/Images/{image_type}?api_key={self.api_key}" 564 | ) 565 | url = self.server_url + endpoint 566 | headers = { 567 | "Content-Type": "image/jpeg", 568 | "X-Emby-Token": self.api_key, 569 | } 570 | 571 | response = requests.post( 572 | url, 573 | headers=headers, 574 | data=image_data_base64, 575 | ) 576 | 577 | if response.status_code == 204: 578 | return True 579 | else: 580 | print(f"Error uploading image for item {item_id}, response: {response}") 581 | return False 582 | 583 | except Exception as e: 584 | print(f"Exception occurred while uploading image: {str(e)}") 585 | return False 586 | 587 | def __update_item(self, item_id, data): 588 | item = self.get_item(item_id) 589 | if item is None: 590 | return None 591 | if "ForcedSortName" in data and "SortName" not in item["LockedFields"]: 592 | # If adding "ForcedSortName" to data, we must have "SortName" in LockedFields 593 | # see https://emby.media/community/index.php?/topic/108814-itemupdateservice-cannot-change-the-sortname-and-forcedsortname/ 594 | item["LockedFields"].append("SortName") 595 | item.update(data) 596 | update_item_url = ( 597 | f"{self.server_url}/emby/Items/{item_id}?api_key={self.api_key}" 598 | ) 599 | try: 600 | response = requests.post(update_item_url, json=item, headers=self.headers) 601 | print( 602 | f"Updated item {item_id} with {data}. Waiting {self.seconds_between_requests} seconds." 603 | ) 604 | time.sleep(self.seconds_between_requests) 605 | return response 606 | except Exception as e: 607 | print(f"Error occurred while updating item: {e}") 608 | return None 609 | 610 | def __add_remove_from_collection( 611 | self, collection_name: str, item_ids: list, operation: str 612 | ) -> int: 613 | 614 | affected_count = 0 615 | 616 | if not item_ids: 617 | return affected_count 618 | 619 | collection_id = self.get_collection_id(collection_name) 620 | 621 | if collection_id is None: 622 | return affected_count 623 | 624 | batch_size = self.api_batch_size 625 | num_batches = (len(item_ids) + batch_size - 1) // batch_size 626 | 627 | print( 628 | f"Processing {collection_name} with '{operation}' in {num_batches} batches" 629 | ) 630 | 631 | for i in range(num_batches): 632 | start_index = i * batch_size 633 | end_index = min((i + 1) * batch_size, len(item_ids)) 634 | batch_item_ids = item_ids[start_index:end_index] 635 | print(".", end="", flush=True) 636 | 637 | if operation == "add": 638 | response = requests.post( 639 | f"{self.server_url}/Collections/{collection_id}/Items/?api_key={self.api_key}&Ids={self.__ids_to_str(batch_item_ids)}" 640 | ) 641 | elif operation == "delete": 642 | response = requests.delete( 643 | f"{self.server_url}/Collections/{collection_id}/Items/?api_key={self.api_key}&Ids={self.__ids_to_str(batch_item_ids)}" 644 | ) 645 | 646 | if response.status_code != 204: 647 | print( 648 | f"Error processing collection with operation '{operation}', response: {response}" 649 | ) 650 | return affected_count 651 | 652 | affected_count += len(batch_item_ids) 653 | time.sleep(self.seconds_between_requests) 654 | 655 | print() 656 | print(f"Finished '{operation}' with {len(item_ids)} items in {collection_name}") 657 | 658 | return affected_count 659 | 660 | @staticmethod 661 | def __ids_to_str(ids: list) -> str: 662 | item_ids = [str(item_id) for item_id in ids] 663 | return ",".join(item_ids) 664 | -------------------------------------------------------------------------------- /src/item_sorting.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import re 3 | 4 | 5 | class ItemSorting: 6 | """ 7 | iterate over categories with sorting required and add sorting name if missing. 8 | Add all items to a list. 9 | 10 | Then get all items from emby that have a sorting name and check if they are in the above list. 11 | If not reset the sorting list. 12 | """ 13 | 14 | def __init__(self, emby): 15 | self.emby = emby 16 | self.items_ids_with_new_sort_names = [] 17 | self.seconds_between_requests = 1 18 | 19 | # Sort name example: "!!![{time_until_2100}]{previous_sort_name}" 20 | # Example: "!!![0000000000]The Matrix" 21 | self.sort_name_start = "!!![" 22 | self.sort_name_end = "]" 23 | self.sort_name_regex = r"!!!\[\d+\]" 24 | 25 | @staticmethod 26 | def minutes_until_2100(iso_date: str): 27 | """ 28 | Returns: 29 | int: The number of minutes remaining until the year 2100. 30 | """ 31 | date = datetime.fromisoformat(iso_date.replace("Z", "+00:00")) 32 | # remove timezone from date 33 | date = date.replace(tzinfo=None) 34 | year_2100 = datetime(2100, 1, 1) 35 | delta = year_2100 - date 36 | minutes = delta.days * 24 * 60 + delta.seconds // 60 37 | return minutes 38 | 39 | def has_sorting_name(self, sort_name: str): 40 | return sort_name.startswith(self.sort_name_start) 41 | 42 | def process_collection(self, collection_id: int): 43 | """ 44 | Iterate over collection with sorting required and add sorting name if missing. 45 | """ 46 | 47 | if collection_id is None: 48 | print("Error: Category ID is None") 49 | return 50 | 51 | # Set DisplayOrder for collection 52 | # https://emby.media/community/index.php?/topic/124081-set-display-order-of-a-collection-with-api/ 53 | self.emby.set_item_property(collection_id, "DisplayOrder", "SortName") 54 | 55 | items_in_collection = self.emby.get_items_in_collection( 56 | collection_id, ["SortName", "DateCreated"] 57 | ) 58 | 59 | if items_in_collection is None: 60 | print(f"Error: Should not return None for collection {collection_id}") 61 | return 62 | 63 | for item in items_in_collection: 64 | # Example item: {'Id': '1541497', 'SortName': 'Elemental', 'DateCreated': '2023-12-08T09:27:58.0000000Z'} 65 | self.items_ids_with_new_sort_names.append(item["Id"]) 66 | if self.has_sorting_name(item["SortName"]): 67 | continue 68 | new_sort_name = f"{self.sort_name_start}{self.minutes_until_2100(item['DateCreated'])}{self.sort_name_end}{item['SortName']}" 69 | self.emby.set_item_property(item["Id"], "ForcedSortName", new_sort_name) 70 | 71 | def __remove_sort_name(self, item: dict): 72 | new_sort_name = re.sub(self.sort_name_regex, "", item["SortName"]) 73 | self.emby.set_item_property(item["Id"], "ForcedSortName", new_sort_name) 74 | 75 | def reset_items_not_in_custom_sort_categories(self): 76 | """ 77 | Get all items from emby that have a sorting name and check if they are in the above list. 78 | If not reset the sorting list. 79 | """ 80 | items_with_sort_name = self.emby.get_items_starting_with_sort_name( 81 | self.sort_name_start 82 | ) 83 | print() 84 | for item in items_with_sort_name: 85 | if item["Id"] not in self.items_ids_with_new_sort_names: 86 | self.__remove_sort_name(item) 87 | 88 | self.items_ids_with_new_sort_names = [] 89 | -------------------------------------------------------------------------------- /src/mdblist.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import quote 2 | import requests 3 | 4 | 5 | class Mdblist: 6 | 7 | def __init__(self, api_key): 8 | self.api_key = api_key 9 | self.user_info_url = "https://api.mdblist.com/user/?apikey=" + api_key 10 | self.my_lists_url = "https://api.mdblist.com/lists/user/?apikey=" + api_key 11 | self.search_lists_url = ( 12 | "https://api.mdblist.com/lists/search?query={query}&apikey=" + api_key 13 | ) 14 | self.get_lists_of_user_url = ( 15 | "https://api.mdblist.com/lists/user/{id}/?apikey=" + api_key 16 | ) 17 | self.items_url = ( 18 | "https://api.mdblist.com/lists/{list_id}/items/?apikey=" + api_key 19 | ) 20 | self.top_lists_url = "https://api.mdblist.com/lists/top?apikey=" + api_key 21 | self.get_list_by_name_url = ( 22 | "https://api.mdblist.com/lists/{username}/{listname}?apikey=" + api_key 23 | ) 24 | self.get_list_by_id_url = ( 25 | "https://api.mdblist.com/lists/{list_id}?apikey=" + api_key 26 | ) 27 | 28 | def get_user_info(self): 29 | try: 30 | response = requests.get(self.user_info_url) 31 | user_info = response.json() 32 | return user_info 33 | except Exception as e: 34 | print(f"Error getting MDBList user info: {e}") 35 | return False 36 | 37 | # Take the json encoded list and return the mediatypes as a list 38 | def check_list_mediatype(self, list) -> list: 39 | mediatypes = [] 40 | for item in list: 41 | if "mediatype" in item and item["mediatype"] not in mediatypes: 42 | mediatypes.append(item["mediatype"]) 43 | return mediatypes 44 | 45 | def get_list( 46 | self, 47 | list_id, 48 | filter_imdb_ids=True, 49 | append_to_response=[], 50 | limit=None, 51 | offset=None, 52 | max_items=None, # <-- Added max_items parameter 53 | ): 54 | """ 55 | Retrieves a list of items from a specified list ID and optionally filters by IMDb IDs. 56 | Supports pagination using limit and offset. By default, fetches all items. 57 | 58 | Args: 59 | list_id (str): The ID of the list to retrieve. 60 | filter_imdb_ids (bool, optional): If True, filters the list to only include IMDb IDs. Defaults to True. 61 | append_to_response (list, optional): Additional parameters to append to the response URL. Defaults to an empty list. 62 | limit (int, optional): Number of items per request. Defaults to None (fetch all). 63 | offset (int, optional): Offset for pagination. Defaults to None. 64 | max_items (int, optional): Maximum number of items to retrieve. Defaults to None (fetch all). 65 | 66 | Returns: 67 | tuple: (list of IMDb IDs or items, list of media types) 68 | """ 69 | all_items = [] 70 | current_offset = offset if offset is not None else 0 71 | page_limit = limit if limit is not None else 1000 72 | 73 | while True: 74 | url = self.items_url.format(list_id=list_id) 75 | params = [] 76 | if append_to_response: 77 | params.append(f"append_to_response={'%2C'.join(append_to_response)}") 78 | params.append(f"limit={page_limit}") 79 | params.append(f"offset={current_offset}") 80 | if params: 81 | url = f"{url}&{'&'.join(params)}" 82 | 83 | response = requests.get(url) 84 | if not response.text: 85 | print(f"No response received from {url}") 86 | return None, None 87 | 88 | try: 89 | result = response.json() 90 | items = result.get("movies", []) + result.get("shows", []) 91 | except Exception: 92 | print(f"Error! Cannot decode json, make sure URL is valid: {url}") 93 | return None, None 94 | 95 | all_items.extend(items) 96 | 97 | # If max_items is set and we've reached/exceeded it, break 98 | if max_items is not None and len(all_items) >= max_items: 99 | all_items = all_items[:max_items] 100 | break 101 | 102 | # Check if more pages are available 103 | has_more = response.headers.get("X-Has-More", "false").lower() == "true" 104 | if not has_more: 105 | break 106 | current_offset += page_limit 107 | 108 | if filter_imdb_ids is False: 109 | return all_items, self.check_list_mediatype(all_items) 110 | 111 | imdb_ids = [] 112 | for item in all_items: 113 | if "imdb_id" in item: 114 | imdb_ids.append(item["imdb_id"]) 115 | else: 116 | print(f"Warning: Could not find imdb_id in item {item}.") 117 | 118 | if len(imdb_ids) == 0: 119 | print(f"ERROR! Cannot find any items in list id {list_id}.") 120 | return imdb_ids, self.check_list_mediatype(all_items) 121 | 122 | def get_list_using_url(self, url): 123 | # Just append /json to end of url to get the json version of the list 124 | # Check first if json is already in the url 125 | if url.endswith("/json"): 126 | url = url[:-5] 127 | 128 | # Make sure that url does not end with / 129 | if url.endswith("/"): 130 | url = url[:-1] 131 | 132 | url = url + "/json" 133 | 134 | response = requests.get(url) 135 | if response.text: 136 | lst = response.json() 137 | imdb_ids = [] 138 | for item in lst: 139 | if "imdb_id" in item: 140 | imdb_ids.append(item["imdb_id"]) 141 | else: 142 | print(f"Could not find imdb_id in item {item}.") 143 | if len(imdb_ids) == 0: 144 | print( 145 | f"ERROR! Cannot find any items in list with api url {url} and public url {url.replace('/json','')}." 146 | ) 147 | return imdb_ids, self.check_list_mediatype(lst) 148 | else: 149 | print(f"No response received from {url}") 150 | return None, None 151 | 152 | def get_list_items_using_url(self, url): 153 | # Just append /json to end of url to get the json version of the list 154 | # Check first if json is already in the url 155 | # Used for external lists. 156 | # TODO There is a better way since External lists support was added to api 157 | if url.endswith("/json"): 158 | url = url[:-5] 159 | 160 | # Make sure that url does not end with / 161 | if url.endswith("/"): 162 | url = url[:-1] 163 | url = url + "/json" 164 | response = requests.get(url) 165 | if response.text: 166 | list = response.json() 167 | """ 168 | It returns like this if empty, we need to handle it 169 | 0 = {'error': 'empty or private list'} 170 | len() = 1 171 | """ 172 | if len(list) == 1 and "error" in list[0]: 173 | print(f"Error! {list[0]['error']}") 174 | return None, None 175 | 176 | return list, self.check_list_mediatype(list) 177 | else: 178 | print(f"No response received from {url}") 179 | return None, None 180 | 181 | def get_my_lists(self) -> list: 182 | # Example return 183 | # [{"id": 45811, "name": "Trending Movies", "slug": "trending-movies", "items": 20, "likes": null, "dynamic": true, "private": false, "mediatype": "movie", "description": ""}] 184 | response = requests.get(self.my_lists_url) 185 | lst = response.json() 186 | return lst 187 | 188 | def find_list_id_by_name(self, list_name): 189 | """ 190 | Lists search 191 | Search public lists by title 192 | 193 | GET https://mdblist.com/api/lists/search?s={query}&apikey={api_key} 194 | query: List Title to search 195 | Response 196 | 197 | [ 198 | { 199 | "id":14, 200 | "name":"Top Watched Movies of The Week / >60", 201 | "slug":"top-watched-movies-of-the-week", 202 | "items":72, 203 | "likes":244, 204 | "user_id":3, 205 | "mediatype":"movie", 206 | "user_name":"linaspurinis", 207 | "description":"", 208 | }, 209 | ] 210 | """ 211 | url = self.search_lists_url.format(list_name=quote(list_name)) 212 | response = requests.get(url) 213 | lists = response.json() 214 | return lists 215 | 216 | def __filter_lists_by_user_name(lists, user_name): 217 | return [lst for lst in lists if lst["user_name"].lower() == user_name.lower()] 218 | 219 | def find_list_id_by_name_and_user(self, list_name, user_name): 220 | lists = self.find_list_id_by_name(list_name) 221 | filtered = Mdblist.__filter_lists_by_user_name(lists, user_name) 222 | if len(filtered) == 0: 223 | return None 224 | if len(filtered) > 1: 225 | print( 226 | f"Warning! Found {len(filtered)} lists with name {list_name} by user {user_name}. Will use the first one." 227 | ) 228 | return filtered[0]["id"] 229 | 230 | def get_lists_of_user(self, user_id): 231 | """ 232 | Get User lists 233 | Returns list of User Lists 234 | 235 | GET https://mdblist.com/api/lists/user/{id}/ 236 | id: user id 237 | Response 238 | [ 239 | { 240 | "id":13, 241 | "name":"Top Watched Movies of The Week for KiDS", 242 | "slug":"top-watched-movies-of-the-week-for-kids", 243 | "items":13, 244 | "likes":50, 245 | "mediatype":"movie", 246 | "description":"", 247 | }, 248 | ] 249 | 250 | """ 251 | url = self.get_lists_of_user_url.format(id=user_id) 252 | response = requests.get(url) 253 | lists = response.json() 254 | return lists 255 | 256 | def get_top_lists(self): 257 | """ 258 | Get Top Lists 259 | Returns list of Top Lists 260 | 261 | GET https://api.mdblist.com/lists/top?apikey=abc123 262 | apikey: Your API key 263 | Response 264 | [ 265 | { 266 | "id": 2194, 267 | "user_id": 1230, 268 | "user_name": "garycrawfordgc", 269 | "name": "Latest TV Shows", 270 | "slug": "latest-tv-shows", 271 | "description": "", 272 | "mediatype": "show", 273 | "items": 300, 274 | "likes": 477 275 | }, 276 | ... 277 | ] 278 | """ 279 | response = requests.get(self.top_lists_url) 280 | top_lists = response.json() 281 | return top_lists 282 | 283 | def search_list(self, query): 284 | """ 285 | Search Lists 286 | Returns list of Lists matching the query 287 | 288 | GET https://api.mdblist.com/lists/search?query=Horror&apikey=abc123 289 | query: Search query 290 | apikey: Your API key 291 | Response 292 | [ 293 | { 294 | "id": 2410, 295 | "user_id": 894, 296 | "user_name": "hdlists", 297 | "name": "Horror Movies (Top Rated From 1960 to Today)", 298 | "slug": "latest-hd-horror-movies-top-rated-from-1980-to-today", 299 | "description": "", 300 | "mediatype": "movie", 301 | "items": 920, 302 | "likes": 139 303 | }, 304 | ... 305 | ] 306 | """ 307 | url = self.search_lists_url.format(query=quote(query)) 308 | response = requests.get(url) 309 | search_results = response.json() 310 | return search_results 311 | 312 | def get_list_by_name(self, username, listname): 313 | """ 314 | Get List by Name 315 | Returns list details matching the username and listname 316 | 317 | GET https://api.mdblist.com/lists/{username}/{listname}?apikey=abc123 318 | username: The username associated with the list 319 | listname: The slug of the list 320 | apikey: Your API key 321 | Response 322 | [ 323 | { 324 | "id": 1176, 325 | "user_id": 3, 326 | "user_name": "linaspurinis", 327 | "name": "Latest Certified Fresh Releases", 328 | "slug": "latest-certified-fresh-releases", 329 | "description": "Score >= 60\r\nLimit 30", 330 | "mediatype": "movie", 331 | "items": 30, 332 | "likes": 21, 333 | "dynamic": true, 334 | "private": false 335 | }, 336 | ... 337 | ] 338 | """ 339 | url = self.get_list_by_name_url.format(username=username, listname=listname) 340 | response = requests.get(url) 341 | list_details = response.json() 342 | return list_details 343 | 344 | def get_list_info_by_id(self, list_id): 345 | """ 346 | Get List Info by ID 347 | Returns list details matching the list ID 348 | 349 | GET https://api.mdblist.com/lists/{listid}?apikey=abc123 350 | listid: The ID of the list 351 | apikey: Your API key 352 | Response 353 | [ 354 | { 355 | "id": 1176, 356 | "user_id": 3, 357 | "user_name": "linaspurinis", 358 | "name": "Latest Certified Fresh Releases", 359 | "slug": "latest-certified-fresh-releases", 360 | "description": "Score >= 60\r\nLimit 30", 361 | "mediatype": "movie", 362 | "items": 30, 363 | "likes": 21, 364 | "dynamic": true, 365 | "private": false 366 | } 367 | ] 368 | """ 369 | url = self.get_list_by_id_url.format(list_id=list_id) 370 | response = requests.get(url) 371 | list_info = response.json() 372 | 373 | if isinstance(list_info, dict): 374 | error = list_info.get("error") 375 | print(f"Error getting list {url} : {error}") 376 | return None 377 | 378 | if not isinstance(list_info, list): 379 | print(f"Error getting list, it should a list! {url} : {list_info}") 380 | return None 381 | 382 | if len(list_info) > 0: # return first list info 383 | return list_info[0] 384 | 385 | print(f"Error getting list! {list_info}") 386 | return None 387 | 388 | def get_list_info_by_url(self, url): 389 | """ 390 | Take an MDBList public URL and return list info. 391 | 392 | Example: 393 | URL: https://mdblist.com/lists/khoegh93/danish-spoken-2011-2020 394 | This function extracts the username and list name from the URL and uses them 395 | to retrieve the list info via the get_list_by_name method. 396 | 397 | Args: 398 | url (str): The MDBList public URL. 399 | 400 | Returns: 401 | dict: The list information retrieved by get_list_by_name. 402 | """ 403 | url = url.replace("https://mdblist.com/lists/", "") 404 | parts = url.split("/") 405 | if len(parts) != 2: 406 | print(f"Error! URL {url} is not in the expected format.") 407 | return None 408 | username = parts[0] 409 | listname = parts[1] 410 | return self.get_list_by_name(username, listname) 411 | 412 | def get_my_limits(self): 413 | """ 414 | Get My Limits 415 | Returns the API usage limits and current usage. 416 | 417 | GET https://api.mdblist.com/user?apikey=abc123 418 | apikey: Your API key 419 | Response 420 | { 421 | "api_requests": 100000, 422 | "api_requests_count": 276, 423 | "user_id": 3, 424 | "patron_status": "active_patron", 425 | "patreon_pledge": 300 426 | } 427 | """ 428 | url = f"https://api.mdblist.com/user?apikey={self.api_key}" 429 | response = requests.get(url) 430 | if response.status_code == 200: 431 | return response.json() 432 | else: 433 | print(f"Error getting limits: {response.status_code}") 434 | return None 435 | -------------------------------------------------------------------------------- /src/refresher.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | 4 | class Refresher: 5 | """ 6 | A class that represents a refresher for processing collections. Helps keeping the ratings in the collections 7 | up to date by refreshing items that are newly added or newly premiered. 8 | 9 | Attributes: 10 | emby (object): An instance of the Emby class. 11 | seconds_between_requests (int): The number of seconds between each request. 12 | processed_items (list): A list of already processed item IDs so they don't get processed again. 13 | """ 14 | 15 | def __init__(self, emby): 16 | self.emby = emby 17 | self.seconds_between_requests = 1 18 | self.processed_items = [] 19 | 20 | def process_collection( 21 | self, 22 | collection_id: int, 23 | max_days_since_added: int, 24 | max_days_since_premiered: int, 25 | show_rating_change: bool = False, 26 | ): 27 | """ 28 | Process a collection based on specified criteria. 29 | Both max_days_since_added and max_days_since_premiered must be satisfied for an item to be refreshed. 30 | 31 | Args: 32 | collection_id (int): The ID of the emby collection to process. 33 | max_days_since_added (int): Will be refreshed if the item was added to Emby less than this number of days ago. 34 | max_days_since_premiered (int): Will be refreshed if the item premiered less than this number of days ago. 35 | show_rating_change (bool): If True, will print the rating change for each item, requires an additional API request for each item. 36 | """ 37 | 38 | items_in_collection = self.emby.get_items_in_collection( 39 | collection_id, ["PremiereDate", "DateCreated", "CommunityRating"] 40 | ) 41 | 42 | current_date = datetime.now() 43 | for item in items_in_collection: 44 | # Example item: {'Id': '1541497', 'PremiereDate': '2023-11-08T09:27:58.0000000Z', 'DateCreated': '2023-12-08T09:27:58.0000000Z'} 45 | # Check if DateCreated is less than 7 days and PremiereDate is less than 30 days 46 | # Current date 47 | 48 | if item["Id"] in self.processed_items: 49 | # print(f"Item already processed: {item['Id']} {item['Name']}") 50 | continue 51 | 52 | self.processed_items.append(item["Id"]) 53 | 54 | created_date = None 55 | try: 56 | created_date = datetime.fromisoformat( 57 | item["DateCreated"].replace("Z", "+00:00") 58 | ) 59 | created_date = created_date.replace(tzinfo=None) 60 | except Exception as e: 61 | print( 62 | f"Error parsing DateCreated ({item['DateCreated']}) for {item['Id']}: {item['Name']}. Error: {e}" 63 | ) 64 | continue 65 | 66 | premier_date = None 67 | 68 | if item["PremiereDate"] is None: 69 | print( 70 | f"Premiere date missing. Setting date to now: {item['Id']} {item['Name']}" 71 | ) 72 | premier_date = current_date 73 | else: 74 | premier_date = datetime.fromisoformat( 75 | item["PremiereDate"].replace("Z", "+00:00") 76 | ) 77 | premier_date = premier_date.replace(tzinfo=None) 78 | 79 | days_since_created = (current_date - created_date).days 80 | days_since_premiered = (current_date - premier_date).days 81 | 82 | if days_since_premiered > max_days_since_premiered: 83 | # print(f"Premiered {max_days_since_premiered} days ago: {item['Name']}") 84 | continue 85 | 86 | if days_since_created > max_days_since_added: 87 | # print(f"Added more than {max_days_since_added} days ago {item['Name']}") 88 | continue 89 | 90 | r = self.emby.refresh_item(item["Id"]) 91 | if r is True: 92 | print(f" {item['Name']}") 93 | if not show_rating_change: 94 | continue 95 | old_rating = item["CommunityRating"] 96 | item = self.emby.get_item(item["Id"]) # Get new rating 97 | if "CommunityRating" not in item: 98 | item["CommunityRating"] = 0 99 | new_rating = item["CommunityRating"] 100 | print(f" Rating change {old_rating} -> {new_rating}") 101 | else: 102 | print(f"ERROR: Item refresh fail: {item['Id']} {item['Name']}") 103 | 104 | 105 | def main(): 106 | pass 107 | 108 | 109 | if __name__ == "__main__": 110 | main() 111 | -------------------------------------------------------------------------------- /src/utils.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | 4 | def find_missing_entries_in_list(list_to_check, list_to_find): 5 | """ 6 | Finds the missing entries in a list. 7 | 8 | Args: 9 | list_to_check (list): The list to check against. 10 | list_to_find (list): The list to find missing entries in. 11 | 12 | Returns: 13 | list: A list of missing entries found in list_to_find. 14 | """ 15 | return [item for item in list_to_find if item not in list_to_check] 16 | 17 | 18 | def minutes_until_2100(): 19 | """ 20 | Used for sorting collection so that the newest show up first in Emby. 21 | Returns: 22 | int: The number of minutes remaining until the year 2100. 23 | """ 24 | today = datetime.now() 25 | year_2100 = datetime(2100, 1, 1) 26 | delta = year_2100 - today 27 | minutes = delta.days * 24 * 60 + delta.seconds // 60 28 | return minutes 29 | --------------------------------------------------------------------------------