Skip to content

Commit c36d22a

Browse files
committed
Merge remote-tracking branch 'origin/main'
2 parents dd897a8 + 60cc29b commit c36d22a

File tree

7 files changed

+293
-15
lines changed

7 files changed

+293
-15
lines changed

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -397,4 +397,7 @@ poetry.toml
397397
# ruff
398398
.ruff_cache/
399399

400-
# End of https://www.toptal.com/developers/gitignore/api/pycharm,flask,python
400+
# End of https://www.toptal.com/developers/gitignore/api/pycharm,flask,python
401+
402+
PLANNING.md
403+
TASK.md

app/blueprints/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from .plex.routes import plex_bp
88
from .notifications.routes import notify_bp
99
from .jellyfin.routes import jellyfin_bp
10+
from .emby.routes import emby_bp
1011

1112
all_blueprints = (public_bp, wizard_bp, admin_bp, auth_bp,
12-
settings_bp, setup_bp, plex_bp, notify_bp, jellyfin_bp)
13+
settings_bp, setup_bp, plex_bp, notify_bp, jellyfin_bp, emby_bp)

app/blueprints/emby/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+

app/blueprints/emby/routes.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from flask import Blueprint, request, jsonify, abort, render_template, redirect
2+
from flask_login import login_required
3+
import logging
4+
import datetime
5+
6+
# Import models needed for routes
7+
from app.models import User, Invitation, Library
8+
9+
# Import the EmbyClient and all helper functions from the service module
10+
from app.services.media.emby import EmbyClient, join, mark_invite_used, folder_name_to_id, set_specific_folders
11+
12+
emby_bp = Blueprint("emby", __name__, url_prefix="/emby")
13+
14+
# Ajax scan with arbitrary creds
15+
@emby_bp.route("/scan", methods=["POST"])
16+
@login_required
17+
def scan():
18+
client = EmbyClient()
19+
url = request.args.get("emby_url")
20+
key = request.args.get("emby_api_key")
21+
if not url or not key:
22+
abort(400)
23+
try:
24+
libs = client.libraries()
25+
return jsonify(libs)
26+
except Exception as e:
27+
logging.error(f"Error scanning Emby libraries: {str(e)}")
28+
abort(400)
29+
30+
# Scan with saved creds
31+
@emby_bp.route("/scan-specific", methods=["POST"])
32+
@login_required
33+
def scan_specific():
34+
client = EmbyClient()
35+
return jsonify(client.libraries())
36+
37+
# The join function is now defined in app.services.media.emby
38+
39+
# Public join endpoint called from the wizard form
40+
@emby_bp.route("/join", methods=["POST"])
41+
def public_join():
42+
ok, msg = join(
43+
username = request.form["username"],
44+
password = request.form["password"],
45+
confirm = request.form["confirm-password"],
46+
email = request.form["email"],
47+
code = request.form["code"],
48+
)
49+
if ok:
50+
return redirect("/wizard/")
51+
return render_template("welcome-jellyfin.html",
52+
username=request.form["username"],
53+
email=request.form["email"],
54+
code=request.form["code"],
55+
server_type="emby",
56+
error=msg)

app/blueprints/public/routes.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def invite(code):
3838
server_type = server_type_setting.value if server_type_setting else None
3939

4040
if server_type == "jellyfin" or server_type == "emby":
41-
return render_template("welcome-jellyfin.html", code=code)
41+
return render_template("welcome-jellyfin.html", code=code, server_type=server_type)
4242
return render_template("user-plex-login.html", code=code)
4343

4444
# ─── POST /join (Plex OAuth or Jellyfin signup) ────────────────────────────
@@ -77,8 +77,8 @@ def join():
7777
daemon=True
7878
).start()
7979
return redirect(url_for("wizard.start"))
80-
elif server_type == "jellyfin":
81-
return render_template("signup-jellyfin.html", code=code)
80+
elif server_type == "jellyfin" or server_type == "emby":
81+
return render_template("welcome-jellyfin.html", code=code, server_type=server_type)
8282

8383
# fallback if server_type missing/unsupported
8484
return render_template("invalid-invite.html", error="Configuration error.")

app/services/media/emby.py

Lines changed: 226 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
import logging
22
import requests
3+
import re
4+
import datetime
35

6+
from sqlalchemy import or_
47
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
611
from .client_base import MediaClient, register_media_client
712

13+
# Reuse the same email regex as jellyfin
14+
EMAIL_RE = re.compile(r"[^@]+@[^@]+\.[^@]+")
15+
816

917
@register_media_client("emby")
1018
class EmbyClient(MediaClient):
@@ -47,16 +55,30 @@ def libraries(self) -> dict[str, str]:
4755
}
4856

4957
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
5160
user = self.post("/Users/New", {"Name": username}).json()
5261
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+
5880
return user_id
59-
81+
6082
def set_policy(self, user_id: str, policy: dict) -> None:
6183
self.post(f"/Users/{user_id}/Policy", policy)
6284

@@ -105,4 +127,199 @@ def list_users(self) -> list[User]:
105127
db.session.delete(dbu)
106128
db.session.commit()
107129

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."

app/templates/welcome-jellyfin.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
</h1>
2424
<p class="mt-2 text-sm text-red-600 dark:text-red-500"><span class="font-medium">{{ error }}</p>
2525

26-
<form class="space-y-4 md:space-y-6" action="{{ url_for("jellyfin.public_join") }}" method="POST">
26+
<form class="space-y-4 md:space-y-6" action="{% if server_type == 'emby' %}{{ url_for('emby.public_join') }}{% else %}{{ url_for('jellyfin.public_join') }}{% endif %}" method="POST">
2727
<div>
2828
<label for="username"
2929
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">{{

0 commit comments

Comments
 (0)