├── .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 |
--------------------------------------------------------------------------------