├── .gitignore ├── README.md ├── dockerfile ├── jellyfin_client.py ├── migrate.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | .vcode/ 3 | __pycache__/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Migrate Plex to Jellyfin 2 | 3 | WIP, project to migrate Plex watched statusses to Jellyfin, based on the file name of the media :) 4 | 5 | ## Getting started 6 | 7 | * Get plex token: https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/ 8 | * Get jellyfin token: Generate one under `Dashboard > API Keys` 9 | 10 | Install requirements (in virtualenv): 11 | ``` 12 | python3 -m venv venv 13 | source venv/bin/activate 14 | pip install -r requirements.txt 15 | ``` 16 | 17 | Command example: 18 | ``` 19 | python3 migrate.py --insecure --debug --plex-url https://plex.test.com:32400 --plex-token 123123123 --jellyfin-url https://jellyfin.test.com --jellyfin-token 123123123 --jellyfin-user user 20 | ``` 21 | 22 | To migrate a Plex managed user to a Jellyfin user, specify the managed user's name in addition to the account's token, for example: 23 | ``` 24 | python3 migrate.py --insecure --debug --plex-url https://plex.test.com:32400 --plex-token 123123123 --plex-managed-user user --jellyfin-url https://jellyfin.test.com --jellyfin-token 123123123 --jellyfin-user user 25 | ``` 26 | 27 | ``` 28 | Usage: migrate.py [OPTIONS] 29 | 30 | Options: 31 | --plex-url TEXT Plex server url [required] 32 | --plex-token TEXT Plex token [required] 33 | --plex-managed-user TEXT Name of a managed user 34 | --jellyfin-url TEXT Jellyfin server url [required] 35 | --jellyfin-token TEXT Jellyfin token [required] 36 | --jellyfin-user TEXT Jellyfin user [required] 37 | --secure / --insecure Verify SSL 38 | --debug / --no-debug Print more output 39 | --no-skip / --skip Skip when no match it found instead of exiting 40 | --dry-run Do not commit changes to Jellyfin 41 | --help Show this message and exit. 42 | ``` 43 | 44 | ## Using Docker image 45 | 46 | In the folder, build the image using: 47 | 48 | ``` 49 | docker build -t migrate-plex-to-jellyfin:local . 50 | ``` 51 | 52 | then run it using the following command: 53 | ``` 54 | docker run migrate-plex-to-jellyfin:local --insecure --debug --plex-url https://plex.test.com:32400 --plex-token 123123123 --jellyfin-url https://jellyfin.test.com --jellyfin-token 123123123 --jellyfin-user user 55 | ``` 56 | -------------------------------------------------------------------------------- /dockerfile: -------------------------------------------------------------------------------- 1 | # BUILD STAGE 2 | FROM python:slim 3 | 4 | WORKDIR /usr/src/app 5 | 6 | COPY requirements.txt ./ 7 | RUN pip install --no-cache-dir -r requirements.txt 8 | 9 | COPY . . 10 | 11 | ENTRYPOINT [ "python3", "migrate.py" ] 12 | 13 | CMD [ "python3", "migrate.py", "--help" ] -------------------------------------------------------------------------------- /jellyfin_client.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | from dataclasses import dataclass 3 | 4 | import requests 5 | 6 | 7 | @dataclass 8 | class JellyFinServer: 9 | url: str 10 | api_key: str 11 | session: requests.Session 12 | 13 | def _get(self, endpoint: str, payload: Optional[dict] = {}) -> dict: 14 | payload['api_key'] = self.api_key 15 | r = self.session.get( 16 | url='{}/{}'.format(self.url, endpoint), params=payload) 17 | return r.json() 18 | 19 | def _post(self, endpoint, payload: Optional[dict] = {}) -> bool: 20 | payload['api_key'] = self.api_key 21 | r = self.session.post( 22 | url='{}/{}'.format(self.url, endpoint), params=payload) 23 | 24 | def get_users(self) -> List[dict]: 25 | """Get all Jellfin user 26 | 27 | Returns: 28 | List[dict]: List of dicts with usernames and ids 29 | """ 30 | users = self._get(endpoint='Users') 31 | result = [] 32 | for u in users: 33 | result.append({ 34 | 'name': u['Name'], 35 | 'id': u['Id'] 36 | }) 37 | return result 38 | 39 | def get_user_id(self, name: str) -> str: 40 | """ Get user id by name 41 | 42 | Returns: 43 | str: user id 44 | """ 45 | for u in self.get_users(): 46 | if u['name'] == name: 47 | return u['id'] 48 | 49 | def get_user_views(self, user_id: str) -> List: 50 | return self._get(endpoint='Users/{}/Views'.format(user_id)) 51 | 52 | def get_all(self, user_id: str) -> List: 53 | """Get all items from JellyFin library 54 | 55 | Returns: 56 | List: List of results 57 | """ 58 | q = { 59 | 'Recursive': True, 60 | 'Fields': 'MediaSources' 61 | } 62 | result = self._get( 63 | endpoint=f"Users/{user_id}/Items", payload=q) 64 | return result['Items'] 65 | 66 | def search_by_provider(self, user_id: str, provider: str, item_id: str) -> List: 67 | """Search items by provider id 68 | 69 | Args: 70 | user_id (str): User id to get items for 71 | provider (str): provider (imdb, tvdb) 72 | 73 | Returns: 74 | List: List of results 75 | """ 76 | q = { 77 | 'IncludeItemTypes': item_type, 78 | 'AnyProviderIdEquals': f"{provider}.{item_id}", 79 | 'Recursive': True, 80 | 'Fields': 'ProviderIds,UserData' 81 | } 82 | result = self._get( 83 | endpoint='Users/{}/Items'.format(user_id), payload=q) 84 | return result['Items'] 85 | 86 | def mark_watched(self, user_id: str, item_id: str): 87 | """Mark item as watched 88 | 89 | Args: 90 | user_id (str): User to mark as watched 91 | item_id (str): itemId to mark as watched 92 | """ 93 | self._post(endpoint='Users/{}/PlayedItems/{}'.format(user_id, item_id)) 94 | -------------------------------------------------------------------------------- /migrate.py: -------------------------------------------------------------------------------- 1 | from typing import List, Set 2 | 3 | import requests 4 | import urllib3 5 | import click 6 | import sys 7 | from loguru import logger 8 | 9 | from plexapi.server import PlexServer 10 | from plexapi import library 11 | from plexapi.media import Media 12 | from jellyfin_client import JellyFinServer 13 | 14 | 15 | LOG_FORMAT = ("{time:YYYY-MM-DD HH:mm:ss.SSS} | " 16 | "{level: <8} | " 17 | "{message} | " 18 | "{extra}") 19 | 20 | @click.command() 21 | @click.option('--plex-url', required=True, help='Plex server url') 22 | @click.option('--plex-token', required=True, help='Plex token') 23 | @click.option('--plex-managed-user', help='Name of a managed user') 24 | @click.option('--jellyfin-url', required=True, help='Jellyfin server url') 25 | @click.option('--jellyfin-token', required=True, help='Jellyfin token') 26 | @click.option('--jellyfin-user', required=True, help='Jellyfin user') 27 | @click.option('--secure/--insecure', help='Verify SSL') 28 | @click.option('--debug/--no-debug', help='Print more output') 29 | @click.option('--no-skip/--skip', help='Skip when no match it found instead of exiting') 30 | @click.option('--dry-run', is_flag=True, help='Do not commit changes to Jellyfin') 31 | def migrate(plex_url: str, plex_token: str, plex_managed_user: str, jellyfin_url: str, 32 | jellyfin_token: str, jellyfin_user: str, 33 | secure: bool, debug: bool, no_skip: bool, dry_run: bool): 34 | logger.remove() 35 | if debug: 36 | logger.add(sys.stderr, format=LOG_FORMAT, level="DEBUG") 37 | else: 38 | logger.add(sys.stderr, format=LOG_FORMAT, level="INFO") 39 | 40 | # Remove insecure request warnings 41 | if not secure: 42 | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 43 | 44 | # Setup sessions 45 | session = requests.Session() 46 | session.verify = secure 47 | plex = PlexServer(plex_url, plex_token, session=session) 48 | 49 | jellyfin = JellyFinServer( 50 | url=jellyfin_url, api_key=jellyfin_token, session=session) 51 | 52 | # Override the Plex session for a managed user 53 | if plex_managed_user: 54 | managed_account = plex.myPlexAccount().user(plex_managed_user) 55 | managed_token = managed_account.get_token(plex.machineIdentifier) 56 | plex = PlexServer(plex_url, managed_token, session=session) 57 | 58 | # Watched list from Plex 59 | plex_watched = set() 60 | 61 | # All the items in jellyfish: 62 | jf_uid = jellyfin.get_user_id(name=jellyfin_user) 63 | jf_library = jellyfin.get_all(user_id=jf_uid) 64 | jf_entries: dict[str, List[dict]] = {} # map of path -> jf library entry 65 | for jf_entry in jf_library: 66 | for source in jf_entry.get("MediaSources", []): 67 | if source["Path"] not in jf_entries: 68 | jf_entries[source["Path"]] = [jf_entry] 69 | else: 70 | jf_entries[source["Path"]].append(jf_entry) 71 | logger.bind(path=source["Path"], id=jf_entry["Id"]).debug("jf entry") 72 | 73 | # Get all Plex watched movies 74 | for section in plex.library.sections(): 75 | if isinstance(section, library.MovieSection): 76 | plex_movies = section 77 | for m in plex_movies.search(unwatched=False): 78 | parts=_watch_parts(m.media) 79 | plex_watched.update(parts) 80 | logger.bind(section=section.title, movie=m, parts=parts).debug("watched movie") 81 | elif isinstance(section, library.ShowSection): 82 | plex_tvshows = section 83 | for show in plex_tvshows.searchShows(**{"episode.unwatched": False}): 84 | for e in show.watched(): 85 | parts=_watch_parts(e.media) 86 | plex_watched.update(parts) 87 | logger.bind(section=section.title, ep=e, parts=parts).debug("watched episode") 88 | 89 | 90 | marked = 0 91 | missing = 0 92 | skipped = 0 93 | for watched in plex_watched: 94 | if watched not in jf_entries: 95 | logger.bind(path=watched).warning("no match found on jellyfin") 96 | missing += 1 97 | continue 98 | for jf_entry in jf_entries[watched]: 99 | if not jf_entry["UserData"]["Played"]: 100 | marked += 1 101 | if dry_run: 102 | message = "Would be marked as watched (dry run)" 103 | else: 104 | jellyfin.mark_watched(user_id=jf_uid, item_id=jf_entry["Id"]) 105 | message = "Marked as watched" 106 | logger.bind(path=watched, jf_id=jf_entry["Id"], title=jf_entry["Name"]).info(message) 107 | else: 108 | skipped += 1 109 | logger.bind(path=watched, jf_id=jf_entry["Id"], title=jf_entry["Name"]).debug("Skipped marking already-watched media") 110 | 111 | message = "Succesfully migrated watched states to Jellyfin" 112 | if dry_run: 113 | message = "Would migrate watched states to Jellyfin" 114 | logger.bind(updated=marked, missing=missing, skipped=skipped).success(message) 115 | 116 | 117 | def _watch_parts(media: List[Media]) -> Set[str]: 118 | watched = set() 119 | for medium in media: 120 | watched.update(map(lambda p: p.file, medium.parts)) 121 | return watched 122 | 123 | if __name__ == '__main__': 124 | migrate() 125 | 126 | import requests 127 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | plexapi 2 | click 3 | requests 4 | loguru 5 | --------------------------------------------------------------------------------