1
1
import logging
2
2
import requests
3
+ import re
4
+ import datetime
3
5
6
+ from sqlalchemy import or_
4
7
from app .extensions import db
5
- from app .models import User
8
+ from app .models import User , Invitation , Library
9
+ from app .services .invites import is_invite_valid
10
+ from app .services .notifications import notify
6
11
from .client_base import MediaClient , register_media_client
7
12
13
+ # Reuse the same email regex as jellyfin
14
+ EMAIL_RE = re .compile (r"[^@]+@[^@]+\.[^@]+" )
15
+
8
16
9
17
@register_media_client ("emby" )
10
18
class EmbyClient (MediaClient ):
@@ -47,16 +55,30 @@ def libraries(self) -> dict[str, str]:
47
55
}
48
56
49
57
def create_user (self , username : str , password : str ) -> str :
50
- # Emby: create user (without password), then set password in a separate call.
58
+ """Create user and set password"""
59
+ # Step 1: Create user without password
51
60
user = self .post ("/Users/New" , {"Name" : username }).json ()
52
61
user_id = user ["Id" ]
53
- # Update the user's password
54
- self .post (
55
- f"/Users/{ user_id } /Password" ,
56
- {"Id" : user_id , "NewPw" : password , "ResetPassword" : True },
57
- )
62
+
63
+ # Step 2: Set password
64
+ try :
65
+ logging .info (f"Setting password for user { username } (ID: { user_id } )" )
66
+ password_response = self .post (
67
+ f"/Users/{ user_id } /Password" ,
68
+ {
69
+ "NewPw" : password ,
70
+ "CurrentPw" : "" , # No current password for new users
71
+ "ResetPassword" : False # Important! Don't reset password
72
+ }
73
+ )
74
+ logging .info (f"Password set response: { password_response .status_code } " )
75
+ except Exception as e :
76
+ logging .error (f"Failed to set password for user { username } : { str (e )} " )
77
+ # Continue with user creation even if password setting fails
78
+ # as we may need to debug further
79
+
58
80
return user_id
59
-
81
+
60
82
def set_policy (self , user_id : str , policy : dict ) -> None :
61
83
self .post (f"/Users/{ user_id } /Policy" , policy )
62
84
@@ -105,4 +127,199 @@ def list_users(self) -> list[User]:
105
127
db .session .delete (dbu )
106
128
db .session .commit ()
107
129
108
- return User .query .all ()
130
+ return User .query .all ()
131
+
132
+
133
+ # Helper functions for Emby operations
134
+
135
+
136
+ def mark_invite_used (inv : Invitation , user : User ) -> None :
137
+ """Mark an invitation as used by a specific user"""
138
+ inv .used = True if not inv .unlimited else inv .used
139
+ inv .used_at = datetime .datetime .now ()
140
+ inv .used_by = user
141
+ db .session .commit ()
142
+
143
+
144
+ def folder_name_to_id (name : str , cache : dict [str , str ]) -> str | None :
145
+ """Convert a folder ID or name to its ID
146
+
147
+ Args:
148
+ name: Either a library name or a library ID
149
+ cache: Dictionary mapping library IDs to library names
150
+
151
+ Returns:
152
+ The library ID if found, None otherwise
153
+ """
154
+ # Check if the name is already a valid ID
155
+ if name in cache :
156
+ return name
157
+
158
+ # Otherwise, try to find the ID by name
159
+ for folder_id , folder_name in cache .items ():
160
+ if folder_name == name :
161
+ return folder_id
162
+
163
+ # Not found
164
+ return None
165
+
166
+
167
+ def set_specific_folders (client : EmbyClient , user_id : str , names : list [str ]):
168
+ """Set specific folders for a user based on selected libraries
169
+
170
+ Args:
171
+ client: The Emby client instance
172
+ user_id: The ID of the user to set permissions for
173
+ names: List of library external_ids to enable for the user
174
+ """
175
+ # Following the same simple approach as the working Jellyfin implementation
176
+ logging .info (f"Setting folder access for user { user_id } with libraries: { names } " )
177
+
178
+ # Get all media folders
179
+ response = client .get ("/Library/MediaFolders" )
180
+ media_folders = response .json ().get ("Items" , [])
181
+
182
+ # Create mapping (match Jellyfin's approach with both ID→Name and Name→ID)
183
+ id_to_name = {item ["Id" ]: item ["Name" ] for item in media_folders }
184
+ name_to_id = {item ["Name" ]: item ["Id" ] for item in media_folders }
185
+
186
+ # Debug info
187
+ logging .info (f"Available libraries: { ', ' .join ([f'{ id } : { name } ' for id , name in id_to_name .items ()])} " )
188
+
189
+ # Convert names to IDs, handling both cases where names could be IDs or actual names
190
+ folder_ids = []
191
+ for name in names :
192
+ # If it's already an ID
193
+ if name in id_to_name :
194
+ folder_ids .append (name )
195
+ logging .info (f"Found direct ID match for { name } : { id_to_name [name ]} " )
196
+ # If it's a name
197
+ elif name in name_to_id :
198
+ folder_ids .append (name_to_id [name ])
199
+ logging .info (f"Found name match for { name } : { name_to_id [name ]} " )
200
+ else :
201
+ logging .warning (f"Could not find library matching: { name } " )
202
+
203
+ # Remove duplicates and None values
204
+ folder_ids = list (set ([fid for fid in folder_ids if fid ]))
205
+
206
+ # Log what we found
207
+ if folder_ids :
208
+ logging .info (f"Matched { len (folder_ids )} libraries: { [id_to_name .get (fid , fid ) for fid in folder_ids ]} " )
209
+ else :
210
+ logging .warning ("No matching libraries found, user will have no access" )
211
+
212
+ # Create simple policy patch - IDENTICAL to Jellyfin implementation
213
+ policy_patch = {
214
+ "EnableAllFolders" : not folder_ids , # True if empty list, False otherwise
215
+ "EnabledFolders" : folder_ids ,
216
+ }
217
+
218
+ # Get current policy
219
+ current = client .get_user (user_id )["Policy" ]
220
+
221
+ # Log what we're doing
222
+ logging .info (f"Setting EnableAllFolders={ policy_patch ['EnableAllFolders' ]} " )
223
+ logging .info (f"Setting EnabledFolders={ policy_patch ['EnabledFolders' ]} " )
224
+
225
+ # Update current policy with our changes
226
+ current .update (policy_patch )
227
+
228
+ # Make sure essential playback permissions are enabled
229
+ playback_permissions = {
230
+ "EnableMediaPlayback" : True ,
231
+ "EnableAudioPlaybackTranscoding" : True ,
232
+ "EnableVideoPlaybackTranscoding" : True ,
233
+ "EnablePlaybackRemuxing" : True ,
234
+ "EnableContentDownloading" : True ,
235
+ "EnableRemoteAccess" : True ,
236
+ }
237
+ current .update (playback_permissions )
238
+
239
+ # Apply the updated policy
240
+ client .set_policy (user_id , current )
241
+
242
+
243
+ def join (username : str , password : str , confirm : str , email : str , code : str ) -> tuple [bool , str ]:
244
+ """Process a join request for a new Emby user"""
245
+ client = EmbyClient ()
246
+
247
+ # Validate input data
248
+ if not EMAIL_RE .fullmatch (email ):
249
+ return False , "Invalid e-mail address."
250
+ if not 8 <= len (password ) <= 20 :
251
+ return False , "Password must be 8–20 characters."
252
+ if password != confirm :
253
+ return False , "Passwords do not match."
254
+
255
+ # Validate invitation code
256
+ ok , msg = is_invite_valid (code )
257
+ if not ok :
258
+ return False , msg
259
+
260
+ # Check for existing users with same username/email
261
+ existing = User .query .filter (
262
+ or_ (User .username == username , User .email == email )
263
+ ).first ()
264
+ if existing :
265
+ return False , "User or e-mail already exists."
266
+
267
+ try :
268
+ # Create the user in Emby
269
+ logging .info (f"Creating Emby user: { username } " )
270
+
271
+ # Step 1: Create the user in Emby
272
+ user_id = client .create_user (username , password )
273
+ logging .info (f"Emby user created with ID: { user_id } " )
274
+
275
+ # Step 2: Get invitation record to determine library access
276
+ inv = Invitation .query .filter_by (code = code ).first ()
277
+
278
+ # Step 3: Determine which libraries to grant access to
279
+ if inv .libraries :
280
+ logging .info (f"Using specific libraries from invitation: { [lib .name for lib in inv .libraries ]} " )
281
+ sections = [lib .external_id for lib in inv .libraries ]
282
+ else :
283
+ logging .info ("No specific libraries in invitation, using all enabled libraries" )
284
+ sections = [
285
+ lib .external_id
286
+ for lib in Library .query .filter_by (enabled = True ).all ()
287
+ ]
288
+
289
+ logging .info (f"Library IDs to enable: { sections } " )
290
+
291
+ # Step 4: Apply folder permissions directly (skip initial policy)
292
+ # This avoids any potential conflicts between multiple policy updates
293
+ set_specific_folders (client , user_id , sections )
294
+ logging .info (f"Applied library permissions for Emby user: { username } " )
295
+
296
+ # Calculate expiration date if needed
297
+ expires = None
298
+ if inv .duration :
299
+ days = int (inv .duration )
300
+ expires = datetime .datetime .utcnow () + datetime .timedelta (days = days )
301
+
302
+ # Create local user record
303
+ new_user = User (
304
+ username = username ,
305
+ email = email ,
306
+ password = "emby-user" , # Not used for auth, just a placeholder
307
+ token = user_id ,
308
+ code = code ,
309
+ expires = expires ,
310
+ )
311
+
312
+ db .session .add (new_user )
313
+ db .session .commit ()
314
+
315
+ # Mark invitation as used
316
+ mark_invite_used (inv , new_user )
317
+ notify ("New User" , f"User { username } has joined your Emby server! 🎉" , tags = "tada" )
318
+
319
+ # Return success
320
+ return True , ""
321
+
322
+ except Exception as e :
323
+ logging .error (f"Emby join error: { str (e )} " , exc_info = True )
324
+ db .session .rollback ()
325
+ return False , "An unexpected error occurred during user creation."
0 commit comments