Skip to content

Commit 27d3a64

Browse files
authored
Merge pull request #1 from brikim/playlistSync
Playlist sync
2 parents d0eebb0 + e7cce16 commit 27d3a64

File tree

5 files changed

+297
-7
lines changed

5 files changed

+297
-7
lines changed

api/emby.py

+104
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,21 @@
11
import requests
2+
import json
23
from logging import Logger
34
from typing import Any
45
from common import utils
6+
from dataclasses import dataclass, field
57

8+
@dataclass
9+
class EmbyPlaylistItem:
10+
name: str
11+
id: str
12+
13+
@dataclass
14+
class EmbyPlaylist:
15+
name: str
16+
id: str
17+
items: list[EmbyPlaylistItem] = field(default_factory=list)
18+
619
class EmbyAPI:
720
def __init__(self, url: str, api_key: str, media_path: str, logger: Logger):
821
self.url = url.rstrip('/')
@@ -172,3 +185,94 @@ def get_library_id(self, name: str) -> Any:
172185
pass
173186

174187
return self.invalid_item_id
188+
189+
def get_playlist_id(self, playlist_name: str) -> str:
190+
try:
191+
payload = {
192+
'api_key': self.api_key,
193+
'SearchTerm': playlist_name,
194+
'Recursive': 'true',
195+
'Fields': 'Path'}
196+
r = requests.get(self.__get_api_url() + '/Items', params=payload)
197+
response = r.json()
198+
199+
for item in response['Items']:
200+
if item['Type'] == 'Playlist' and item['Name'] == playlist_name:
201+
return item['Id']
202+
203+
except Exception as e:
204+
self.logger.error("{} get_item_id_from_path {} {}".format(self.log_header, utils.get_tag('path', path), utils.get_tag('error', e)))
205+
206+
return self.get_invalid_item_id()
207+
208+
def __get_comma_separated_list(self, list_to_separate: list[str]) -> str:
209+
comma_separated_ids = ''
210+
for list_item in list_to_separate:
211+
comma_separated_ids += list_item + ','
212+
return comma_separated_ids.rstrip(',')
213+
214+
def create_playlist(self, playlist_name: str, ids: list[str]) -> str:
215+
try:
216+
headers = {'accept': 'application/json'}
217+
payload = {
218+
'Name': playlist_name,
219+
'Ids': self.__get_comma_separated_list(ids),
220+
'MediaType': 'Movies'}
221+
embyUrl = self.__get_api_url() + '/Playlists' + '?api_key=' + self.api_key
222+
r = requests.post(embyUrl, headers=headers, data=payload)
223+
if r.status_code < 300:
224+
response = r.json()
225+
return response['Id']
226+
except Exception as e:
227+
self.logger.error("{} create_playlist {} error {}".format(self.log_header, utils.get_tag('playlist', playlist_name), utils.get_tag('error', e)))
228+
return self.invalid_item_id()
229+
230+
def get_playlist_items(self, playlist_id: str) -> EmbyPlaylist:
231+
try:
232+
playlist = self.search_item(playlist_id)
233+
234+
payload = {
235+
'api_key': self.api_key}
236+
r = requests.get(self.__get_api_url() + '/Playlists/' + playlist_id + '/Items', params=payload)
237+
response = r.json()
238+
239+
emby_playlist = EmbyPlaylist(playlist['Name'], playlist_id)
240+
for item in response['Items']:
241+
emby_playlist.items.append(EmbyPlaylistItem(item['Name'], item['Id']))
242+
243+
return emby_playlist
244+
except Exception as e:
245+
self.logger.error("{} get_playlist_items {} error {}".format(self.log_header, utils.get_tag('playlist_id', playlist_id), utils.get_tag('error', e)))
246+
247+
return None
248+
249+
def add_playlist_items(self, playlist_id: str, item_ids: list[str]):
250+
try:
251+
headers = {'accept': 'application/json'}
252+
payload = {
253+
'Ids ': self.__get_comma_separated_list(item_ids)}
254+
embyUrl = self.__get_api_url()+ '/Playlists/' + playlist_id + '/Items' + '?api_key=' + self.api_key
255+
r = requests.post(embyUrl, headers=headers, data=payload)
256+
pass
257+
except Exception as e:
258+
self.logger.error("{} add_playlist_items {} {} error {}".format(self.log_header, utils.get_tag('playlist_id', playlist_id), utils.get_tag('item_ids', item_ids), utils.get_tag('error', e)))
259+
260+
def remove_playlist_items(self, playlist_id: str, item_ids: list[str]):
261+
try:
262+
headers = {'accept': 'application/json'}
263+
payload = {
264+
'EntryIds': self.__get_comma_separated_list(item_ids)}
265+
embyUrl = self.__get_api_url()+ '/Playlists/' + playlist_id + '/Items/Delete' + '?api_key=' + self.api_key
266+
r = requests.post(embyUrl, headers=headers, data=payload)
267+
pass
268+
except Exception as e:
269+
self.logger.error("{} remove_playlist_item {} {} error {}".format(self.log_header, utils.get_tag('playlist_id', playlist_id), utils.get_tag('item_ids', item_ids), utils.get_tag('error', e)))
270+
271+
def set_move_playlist_item_to_index(self, playlist_id: str, item_id: str, index: int):
272+
try:
273+
headers = {'accept': 'application/json'}
274+
embyUrl = self.__get_api_url()+ '/Playlists/' + playlist_id + '/Items/' + item_id + '/Move/' + str(index) + '?api_key=' + self.api_key
275+
r = requests.post(embyUrl, headers=headers)
276+
pass
277+
except Exception as e:
278+
self.logger.error("{} set_move_playlist_item_to_index {} {} {} error {}".format(self.log_header, utils.get_tag('playlist_id', playlist_id), utils.get_tag('item_id', item_id), utils.get_tag('move_index', index), utils.get_tag('error', e)))

api/plex.py

+41-6
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,24 @@
11
from logging import Logger
22
from typing import Any
3-
from plexapi.server import PlexServer
3+
from plexapi import server, collection
44
from common import utils
5+
from dataclasses import dataclass, field
56

7+
@dataclass
8+
class PlexCollectionItem:
9+
title: str
10+
path: str
11+
12+
@dataclass
13+
class PlexCollection:
14+
name: str
15+
items: list[PlexCollectionItem] = field(default_factory=list)
16+
617
class PlexAPI:
718
def __init__(self, url: str, api_key: str, admin_user_name: str, media_path: str, logger: Logger):
819
self.url = url
920
self.api_key = api_key
10-
self.plex_server = PlexServer(url.rstrip('/'), api_key)
21+
self.plex_server = server.PlexServer(url.rstrip('/'), api_key)
1122
self.admin_user_name = admin_user_name
1223
self.media_path = media_path
1324
self.logger = logger
@@ -46,7 +57,7 @@ def switch_plex_account(self, user_name):
4657
if current_user.username != user_name:
4758
self.plex_server.switchUser(user_name)
4859
except Exception as e:
49-
self.logger.error("{} switch_plex_account {} {}".format(self.log_header, utils.get_tag('user', user_name), utils.get_tag('error', e)))
60+
self.logger.error('{} switch_plex_account {} {}'.format(self.log_header, utils.get_tag('user', user_name), utils.get_tag('error', e)))
5061

5162
def switch_plex_account_admin(self):
5263
self.switch_plex_account(self.admin_user_name)
@@ -81,7 +92,7 @@ def set_library_scan(self, library_name: str):
8192
library = self.plex_server.library.section(library_name)
8293
library.update()
8394
except Exception as e:
84-
self.logger.error("{} set_library_scan {} {}".format(self.log_header, utils.get_tag('library', library_name), utils.get_tag('error', e)))
95+
self.logger.error('{} set_library_scan {} {}'.format(self.log_header, utils.get_tag('library', library_name), utils.get_tag('error', e)))
8596

8697
def get_library_name_from_path(self, path: str) -> str:
8798
# Get all libraries
@@ -91,5 +102,29 @@ def get_library_name_from_path(self, path: str) -> str:
91102
if location == path:
92103
return library.title
93104

94-
self.logger.warning("{} No library found with {}".format(self.log_header, utils.get_tag('path', path)))
95-
return ''
105+
self.logger.warning('{} No library found with {}'.format(self.log_header, utils.get_tag('path', path)))
106+
return ''
107+
108+
def get_collection_valid(self, library_name: str, collection_name: str) -> bool:
109+
try:
110+
library = self.plex_server.library.section(library_name)
111+
for collection in library.collections():
112+
if collection.title == collection_name:
113+
return True
114+
except Exception as e:
115+
pass
116+
return False
117+
118+
def get_collection(self, library_name: str, collection_name: str) -> PlexCollection:
119+
try:
120+
library = self.plex_server.library.section(library_name)
121+
for collection in library.collections():
122+
if collection.title == collection_name:
123+
items: list[PlexCollectionItem] = []
124+
for item in collection.children:
125+
if len(item.locations) > 0:
126+
items.append(PlexCollectionItem(item.title, item.locations[0]))
127+
return PlexCollection(collection.title, items)
128+
except Exception as e:
129+
pass
130+
return self.get_invalid_type()

app.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
Media Utilities
33
"""
44

5-
version = 'v2.1.0'
5+
version = 'v2.2.0'
66

77
import sys
88
import os
@@ -29,6 +29,7 @@
2929
from service.DvrMaintainer import DvrMaintainer
3030
from service.FolderCleanup import FolderCleanup
3131
from service.SyncWatched import SyncWatched
32+
from service.PlaylistSync import PlaylistSync
3233

3334
# Global Variables #######
3435
logger = logging.getLogger(__name__)
@@ -161,6 +162,8 @@ def do_nothing():
161162
services.append(DvrMaintainer('\33[95m', plex_api, emby_api, data['dvr_maintainer'], logger, scheduler))
162163
if 'folder_cleanup' in data and data['folder_cleanup']['enabled'] == 'True':
163164
services.append(FolderCleanup('\33[33m', plex_api, emby_api, data['folder_cleanup'], logger, scheduler))
165+
if 'playlist_sync' in data and data['playlist_sync']['enabled'] == 'True':
166+
services.append(PlaylistSync('\33[94m', plex_api, emby_api, data['playlist_sync'], logger, scheduler))
164167

165168
# ########################################################
166169

config/configDefault.conf

+8
Original file line numberDiff line numberDiff line change
@@ -89,5 +89,13 @@
8989
"ignore_file_in_empty_check": [
9090
{"ignore_file": "someFileToIgnore"}
9191
]
92+
},
93+
94+
"playlist_sync": {
95+
"enabled": "True",
96+
"cron_run_rate": "0 */2",
97+
"plex_collection_sync": {
98+
{"library": "plexLibraryName", "collection_name": "plexCollectionName", "target_servers": "emby"}
99+
}
92100
}
93101
}

service/PlaylistSync.py

+140
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
from logging import Logger
2+
from apscheduler.schedulers.blocking import BlockingScheduler
3+
from dataclasses import dataclass, field
4+
from common import utils
5+
6+
from service.ServiceBase import ServiceBase
7+
8+
from api.plex import PlexAPI, PlexCollection
9+
from api.emby import EmbyAPI, EmbyPlaylist
10+
11+
@dataclass
12+
class PlexCollectionConfig:
13+
library_name: str
14+
collection_name: str
15+
target_servers: list[str] = field(default_factory=list)
16+
17+
class PlaylistSync(ServiceBase):
18+
def __init__(self, ansi_code: str, plex_api: PlexAPI, emby_api: EmbyAPI, config, logger: Logger, scheduler: BlockingScheduler):
19+
super().__init__(ansi_code, self.__module__, config, logger, scheduler)
20+
21+
self.plex_api = plex_api
22+
self.emby_api = emby_api
23+
self.plex_collection_configs: list[PlexCollectionConfig] = []
24+
25+
try:
26+
for plex_collection in config['plex_collection_sync']:
27+
if 'library' in plex_collection and 'collection_name' in plex_collection and 'target_servers' in plex_collection:
28+
library_name = plex_collection['library']
29+
collection_name = plex_collection['collection_name']
30+
target_servers: list[str] = []
31+
32+
target_server_names = plex_collection['target_servers'].split(',')
33+
for target_server_name in target_server_names:
34+
supported_target_server_name = self.__server_supported(target_server_name)
35+
if supported_target_server_name != '':
36+
target_servers.append(supported_target_server_name)
37+
38+
if library_name != '' and collection_name != '' and len(target_servers) > 0:
39+
if plex_api.get_valid() is True and plex_api.get_collection_valid(library_name, collection_name) is False:
40+
self.log_warning('{} {} {} not found on server'.format(utils.get_formatted_plex(), utils.get_tag('library', library_name), utils.get_tag('collection', collection_name)))
41+
42+
self.plex_collection_configs.append(PlexCollectionConfig(library_name, collection_name, target_servers))
43+
44+
except Exception as e:
45+
self.log_error('Read config {}'.format(utils.get_tag('error', e)))
46+
47+
def __server_supported(self, server_type: str) -> str:
48+
lower_name = server_type.lower()
49+
if (lower_name == 'plex' or lower_name == 'emby'):
50+
return lower_name
51+
return ''
52+
53+
def __sync_emby_playlist(self, emby_item_ids: list[str], emby_playlist: EmbyPlaylist):
54+
# Check if any items were added to the playlist
55+
added_items: list[str] = []
56+
for emby_item_id in emby_item_ids:
57+
item_id_found = False
58+
for item in emby_playlist.items:
59+
if emby_item_id == item.id:
60+
item_id_found = True
61+
break
62+
if item_id_found is False:
63+
added_items.append(emby_item_id)
64+
65+
# Check if any items were deleted out of the playlist
66+
deleted_items: list[str] = []
67+
for item in emby_playlist.items:
68+
if item.id not in emby_item_ids:
69+
deleted_items.append(item.id)
70+
71+
# Check if the order of the playlist items changed
72+
playlist_changed = False
73+
if len(emby_item_ids) == len(emby_playlist.items):
74+
playlist_index = 0
75+
for item_id in emby_item_ids:
76+
if item_id != emby_playlist.items[playlist_index].id:
77+
playlist_changed = True
78+
break
79+
playlist_index += 1
80+
else:
81+
playlist_changed = True
82+
83+
if playlist_changed is True or len(added_items) > 0 or len(deleted_items) > 0:
84+
self.log_info('Syncing {} {} to {}'.format(utils.get_formatted_plex(), utils.get_tag('collection', emby_playlist.name), utils.get_formatted_emby()))
85+
86+
if len(added_items) > 0:
87+
self.emby_api.add_playlist_items(emby_playlist.id, added_items)
88+
89+
if len(deleted_items) > 0:
90+
self.emby_api.remove_playlist_items(emby_playlist.id, deleted_items)
91+
92+
current_index = 1
93+
for item_id in emby_item_ids:
94+
self.emby_api.set_move_playlist_item_to_index(emby_playlist.id, item_id, current_index)
95+
current_index += 1
96+
97+
def __sync_emby_playlist_with_plex_collection(self, plex_collection: PlexCollection):
98+
emby_item_ids: list[str] = []
99+
for plex_item in plex_collection.items:
100+
emby_item_id = self.emby_api.get_item_id_from_path(plex_item.path)
101+
if emby_item_id != self.emby_api.get_invalid_item_id():
102+
emby_item_ids.append(emby_item_id)
103+
else:
104+
self.log_warning('{} sync {} {} item not found {}'.format(utils.get_emby_ansi_code(), utils.get_plex_ansi_code(), utils.get_tag('collection', plex_collection.name), utils.get_tag('item', plex_item.title)))
105+
106+
emby_playlist_id = self.emby_api.get_playlist_id(plex_collection.name)
107+
if emby_playlist_id == self.emby_api.get_invalid_item_id():
108+
self.emby_api.create_playlist(plex_collection.name, emby_item_ids)
109+
self.log_info('Syncing {} {} to {}'.format(utils.get_formatted_plex(), utils.get_tag('collection', plex_collection.name), utils.get_formatted_emby()))
110+
else:
111+
emby_playlist:EmbyPlaylist = self.emby_api.get_playlist_items(emby_playlist_id)
112+
if emby_playlist is not None:
113+
self.__sync_emby_playlist(emby_item_ids, emby_playlist)
114+
115+
def __sync_plex_collection(self, collection_config: PlexCollectionConfig):
116+
collection: PlexCollection = self.plex_api.get_collection(collection_config.library_name, collection_config.collection_name)
117+
if collection != self.plex_api.get_invalid_type():
118+
for target_server in collection_config.target_servers:
119+
if target_server == 'emby' and self.emby_api.get_valid() is True:
120+
self.__sync_emby_playlist_with_plex_collection(collection)
121+
122+
def __sync_playlists(self):
123+
if len(self.plex_collection_configs) > 0:
124+
plex_valid = self.plex_api.get_valid()
125+
emby_valid = self.emby_api.get_valid()
126+
if plex_valid is True and emby_valid is True:
127+
for plex_collection_config in self.plex_collection_configs:
128+
self.__sync_plex_collection(plex_collection_config)
129+
else:
130+
if plex_valid is False:
131+
self.log_warning(self.plex_api.get_connection_error_log())
132+
if emby_valid is False:
133+
self.log_warning(self.emby_api.get_connection_error_log())
134+
135+
def init_scheduler_jobs(self):
136+
if self.cron is not None:
137+
self.log_service_enabled()
138+
self.scheduler.add_job(self.__sync_playlists, trigger='cron', hour=self.cron.hours, minute=self.cron.minutes)
139+
else:
140+
self.log_warning('Enabled but will not Run. Cron is not valid!')

0 commit comments

Comments
 (0)