Jellyfin Forum
Playlist sharing with write permission - Printable Version

+- Jellyfin Forum (https://forum.jellyfin.org)
+-- Forum: Support (https://forum.jellyfin.org/f-support)
+--- Forum: Guides, Walkthroughs & Tutorials (https://forum.jellyfin.org/f-guides-walkthroughs-tutorials)
+--- Thread: Playlist sharing with write permission (/t-playlist-sharing-with-write-permission)



Playlist sharing with write permission - I-G-1-1 - 2025-08-13

I (and some AI) wrote this code to make possible to grant write permission to a shared playlist to a user that doesn't own the playlist.

At some point It was really simple to achieve write permission simple adding this:

  <Shares>
    <Share>
      <UserId>xxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxx</UserId>
      <CanEdit>true</CanEdit>
    </Share>
  </Shares>

to the /var/lib/jellyfin/data/playlists/[PLAYLIST_NAME]/playlist.xml of any playlist.

At the moment in Jellyfin 10.10.7 this manual change doesn't grant any permission and as soon as I add a song to the playlist the manual change is deleted from the playlist.xml file.

So It seems that the only way to grant write permission to a shared playlist is using the API (https://api.jellyfin.org/#tag/Playlists/operation/UpdatePlaylistUser). So I (and the AI) had to write this python script.

HOW TO INSTALL:

sudo apt-get install python3.11-venv
cd
mkdir JFPllstAccssCntrll
cd JFPllstAccssCntrll
python3 -m venv .
source ~/JFPllstAccssCntrll/bin/activate
nano requirements.txt
'
requests>=2.0
'
pip3 install -r requirements.txt


nano JFPllstAccssCntrll.py and paste the script:

Code:
#!/usr/bin/env python3
"""
Script to manage Jellyfin playlist write permissions.

  • --list            : group by owner and print ASCII subtables
  • [user] [playlist] : grant or revoke edit permission (use --revoke)
  • --delay N        : seconds to wait after refresh before listing (default 5)
"""

import os
import sqlite3
import argparse
import getpass
import requests
import xml.etree.ElementTree as ET
import time

# --- 1) CONFIGURATION --------------------------------------------------

JELLYFIN_BASE = "http://localhost:8096"
PLAYLISTS_DIR = "/var/lib/jellyfin/data/playlists"
LIBRARY_DB    = "/var/lib/jellyfin/data/library.db"
USERS_DB      = "/var/lib/jellyfin/data/jellyfin.db"

CLIENT_HDR = (
    'Mediabrowser Client="script", '
    'Device="script", DeviceId="script", Version="1.0.0"'
)

# --- 2) AUTH UTILITIES -------------------------------------------------

def normalize_id(raw_id):
    return raw_id.replace("-", "").lower()

def authenticate(username):
    """
    Prompt for a password and authenticate to Jellyfin by username.
    Returns an access token.
    """
    pw = getpass.getpass(f"Password for '{username}': ")
    url = f"{JELLYFIN_BASE}/Users/AuthenticateByName"
    headers = {
        "accept": "application/json",
        "Content-Type": "application/json",
        "x-emby-authorization": CLIENT_HDR
    }
    resp = requests.post(url, json={"Username": username, "Pw": pw}, headers=headers)
    resp.raise_for_status()
    data = resp.json()
    return data.get("Data", data).get("AccessToken")

# --- 3) SINGLE-PLAYLIST REFRESH ----------------------------------------

def refresh_playlist(playlist_id, token):
    """
    Force reload of the specified playlist.
    Requires an Admin access token.
    """
    url = f"{JELLYFIN_BASE}/Items/{playlist_id}/Refresh"
    headers = {
        "accept": "application/json",
        "Content-Type": "application/json",
        "X-MediaBrowser-Token": token
    }
    resp = requests.post(url, headers=headers)
    try:
        resp.raise_for_status()
        print(f"✔ Playlist {playlist_id} successfully refreshed (Admin).")
    except Exception as e:
        print(f"✗ Error refreshing playlist {playlist_id}: {e}")

# --- 4) DATABASE AND FILESYSTEM LOADERS ---------------------------------

def load_users():
    conn = sqlite3.connect(USERS_DB)
    cur  = conn.cursor()
    cur.execute("SELECT Username, Id FROM Users")
    mapping = { normalize_id(uid): name for name, uid in cur.fetchall() }
    conn.close()
    return mapping

def load_playlists():
    conn = sqlite3.connect(LIBRARY_DB)
    cur  = conn.cursor()
    cur.execute("""
        SELECT Name, PresentationUniqueKey
        FROM TypedBaseItems
        WHERE Type='MediaBrowser.Controller.Playlists.Playlist'
    """)
    mapping = { name: pid.lower() for name, pid in cur.fetchall() if pid }
    conn.close()
    return mapping

def load_owners():
    owners = {}
    for d in os.listdir(PLAYLISTS_DIR):
        xml_path = os.path.join(PLAYLISTS_DIR, d, "playlist.xml")
        if not os.path.isfile(xml_path):
            continue
        tree = ET.parse(xml_path)
        owner_id = tree.findtext("OwnerUserId")
        if owner_id:
            owners[d] = normalize_id(owner_id)
    return owners

# --- 5) LIST PERMISSIONS GROUPED BY OWNER -----------------------------

def list_permissions():
    """
    Group playlists by owner and print ASCII subtables.
    No authentication needed since it reads from disk/DB only.
    """
    users    = load_users()
    playlists = load_playlists()
    owners    = load_owners()

    print("\n=== Current Permissions ===\n")
    by_owner = {}
    for pname in playlists:
        owner_norm = owners.get(pname)
        if not owner_norm:
            continue
        by_owner.setdefault(owner_norm, []).append(pname)

    for owner_norm, pnames in by_owner.items():
        owner_name = users.get(owner_norm, "<unknown>")
        print(f"Owner: {owner_name} ({owner_norm})")
        rows = []
        for pname in sorted(pnames):
            xml_path = os.path.join(PLAYLISTS_DIR, pname, "playlist.xml")
            editors  = []
            if os.path.isfile(xml_path):
                root = ET.parse(xml_path).getroot()
                for share in root.findall("Shares/Share"):
                    if share.findtext("CanEdit") == "true":
                        uid_norm = normalize_id(share.findtext("UserId") or "")
                        if uid_norm != owner_norm:
                            editors.append(users.get(uid_norm, uid_norm))
            rows.append([pname, ", ".join(editors) or "-"])

        headers    = ["Playlist", "CanEdit by"]
        col_widths = [max(len(str(c)) for c in col) for col in zip(headers, *rows)]
        sep = "+" + "+".join("-"*(w+2) for w in col_widths) + "+"

        print(sep)
        print("| " + " | ".join(h.ljust(w) for h, w in zip(headers, col_widths)) + " |")
        print(sep)
        for row in rows:
            print("| " + " | ".join(str(c).ljust(w) for c, w in zip(row, col_widths)) + " |")
        print(sep + "\n")

# --- 6) MODIFY PERMISSION + AUTO-LIST ---------------------------------

def modify_permission(target_user, playlist, revoke=False, delay=5):
    users    = load_users()
    playlists = load_playlists()
    owners    = load_owners()

    if playlist not in playlists:
        raise SystemExit(f"Playlist '{playlist}' not found.")
    if target_user not in users.values():
        raise SystemExit(f"User '{target_user}' not found.")

    pid  = playlists[playlist]
    tuid  = next(u for u, n in users.items() if n == target_user)
    onorm = owners.get(playlist)
    if not onorm:
        raise SystemExit("Cannot determine playlist owner.")

    # 1) Owner authentication and grant/revoke
    owner_name  = users[onorm]
    print(f"Playlist '{playlist}' ({pid}) is owned by {owner_name}.")
    token_owner = authenticate(owner_name)

    headers = {
        "accept":              "application/json",
        "Content-Type":        "application/json",
        "X-MediaBrowser-Token": token_owner
    }
    url  = f"{JELLYFIN_BASE}/Playlists/{pid}/Users/{tuid}"
    body = {"UserId": tuid, "CanEdit": not revoke}
    resp = requests.post(url, headers=headers, json=body)
    action = "revoked" if revoke else "granted"
    if resp.status_code in (200, 204):
        print(f"Edit permission {action} for '{target_user}'.")
    else:
        print(f"Error {resp.status_code}: {resp.text}")
        return

    # 2) Admin authentication and refresh single playlist
    print("Admin authentication to refresh playlist…")
    admin_user  = input("Admin username: ")
    admin_token = authenticate(admin_user)
    print(f"Triggering refresh for playlist '{playlist}' ({pid})…")
    refresh_playlist(pid, admin_token)

    # 2.b) Delay to allow DB to synchronize
    if delay > 0:
        print(f"Waiting {delay} seconds to allow DB update…")
        time.sleep(delay)

    # 3) Immediately show updated permissions
    list_permissions()

# --- 7) ENTRYPOINT AND ARG PARSING -------------------------------------

def main():
    parser = argparse.ArgumentParser(
        description="Manage Jellyfin playlist write permissions"
    )
    parser.add_argument("user", nargs="?", help="Target username")
    parser.add_argument("playlist", nargs="?", help="Playlist name")
    parser.add_argument("--revoke", action="store_true", help="Revoke edit permission")
    parser.add_argument("--list", action="store_true",
                        help="List permissions grouped by owner")
    parser.add_argument("--delay", type=int, default=5,
                        help="Seconds to wait after refresh (default: 5)")
    args = parser.parse_args()

    if args.list:
        list_permissions()
    elif args.user and args.playlist:
        modify_permission(args.user, args.playlist,
                          revoke=args.revoke,
                          delay=args.delay)
    else:
        parser.print_help()

if __name__ == "__main__":
    main()

HOW TO USE: (it needs sudo only because it needs to access to the jellyfin database so or you run it as the jellyfin user or you run it with sudo)
sudo ~/JFPllstAccssCntrll/bin/python3 ~/JFPllstAccssCntrll/JFPllstAccssCntrll.py

Code:
usage: JFPllstAccssCntrll.py [-h] [--revoke] [--list] [--delay DELAY] [user] [playlist]
Manage Jellyfin playlist write permissions
positional arguments:
  user           Target username
  playlist       Playlist name
options:
  -h, --help     show this help message and exit
  --revoke       Revoke edit permission
  --list         List permissions grouped by owner
  --delay DELAY  Seconds to wait after refresh (default: 5)


sudo ~/JFPllstAccssCntrll/bin/python3 ~/JFPllstAccssCntrll/JFPllstAccssCntrll.py --list 
give you a list off all the playlist divided by owner and with a column that say who can edit the playlist other than the owner. It will ask the username and the password of an Admin

sudo ~/JFPllstAccssCntrll/bin/python3 ~/JFPllstAccssCntrll/JFPllstAccssCntrll.py [USER_TO_GRANT_PERMISSION] [PLAYLIST_NAME] 
to grant permission. It will ask the password of the owner of the playlist.

sudo ~/JFPllstAccssCntrll/bin/python3 ~/JFPllstAccssCntrll/JFPllstAccssCntrll.py [USER_TO_REVOKE_PERMISSION] [PLAYLIST_NAME] --revoke 
to revoke permission. It will ask the password of the owner of the playlist.

Now you have a shared playlist that can be changed from the users you granted permission.

If you want to hide this playlist to specific users I suggest you to add a tag to the playlist (for example "playlist_user01") and then in "settings/user/[USERNAME]/parentalcontrol/block/Locking Items with tag" add playlist_user01 for each user you want to hide the playlist.


RE: Playlist sharing with write permission - Frank23t - 2025-08-22

(2025-08-13, 01:35 AM)I-G-1-1 Wrote: I (and some AI) wrote this code to make possible to grant write permission to a shared playlist to a user that doesn't own the playlist.

At some point It was really simple to achieve write permission simple adding this:

  <Shares>
    <Share>
      <UserId>xxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxx</UserId>
      <CanEdit>true</CanEdit>
    </Share>
  </Shares>

to the /var/lib/jellyfin/data/playlists/[PLAYLIST_NAME]/playlist.xml of any playlist.

At the moment in Jellyfin 10.10.7 this manual change doesn't grant any permission and as soon as I add a song to the playlist the manual change is deleted from the playlist.xml file.

So It seems that the only way to grant write permission to a shared playlist is using the API (https://api.jellyfin.org/#tag/Playlists/operation/UpdatePlaylistUser). So I (and the AI) had to write this python script.
hot games
HOW TO INSTALL:

sudo apt-get install python3.11-venv
cd
mkdir JFPllstAccssCntrll
cd JFPllstAccssCntrll
python3 -m venv .
source ~/JFPllstAccssCntrll/bin/activate
nano requirements.txt
'
requests>=2.0
'
pip3 install -r requirements.txt


nano JFPllstAccssCntrll.py and paste the script:

Code:
#!/usr/bin/env python3
"""
Script to manage Jellyfin playlist write permissions.

  • --list            : group by owner and print ASCII subtables
  • [user] [playlist] : grant or revoke edit permission (use --revoke)
  • --delay N        : seconds to wait after refresh before listing (default 5)
"""

import os
import sqlite3
import argparse
import getpass
import requests
import xml.etree.ElementTree as ET
import time

# --- 1) CONFIGURATION --------------------------------------------------

JELLYFIN_BASE = "http://localhost:8096"
PLAYLISTS_DIR = "/var/lib/jellyfin/data/playlists"
LIBRARY_DB    = "/var/lib/jellyfin/data/library.db"
USERS_DB      = "/var/lib/jellyfin/data/jellyfin.db"

CLIENT_HDR = (
    'Mediabrowser Client="script", '
    'Device="script", DeviceId="script", Version="1.0.0"'
)

# --- 2) AUTH UTILITIES -------------------------------------------------

def normalize_id(raw_id):
    return raw_id.replace("-", "").lower()

def authenticate(username):
    """
    Prompt for a password and authenticate to Jellyfin by username.
    Returns an access token.
    """
    pw = getpass.getpass(f"Password for '{username}': ")
    url = f"{JELLYFIN_BASE}/Users/AuthenticateByName"
    headers = {
        "accept": "application/json",
        "Content-Type": "application/json",
        "x-emby-authorization": CLIENT_HDR
    }
    resp = requests.post(url, json={"Username": username, "Pw": pw}, headers=headers)
    resp.raise_for_status()
    data = resp.json()
    return data.get("Data", data).get("AccessToken")

# --- 3) SINGLE-PLAYLIST REFRESH ----------------------------------------

def refresh_playlist(playlist_id, token):
    """
    Force reload of the specified playlist.
    Requires an Admin access token.
    """
    url = f"{JELLYFIN_BASE}/Items/{playlist_id}/Refresh"
    headers = {
        "accept": "application/json",
        "Content-Type": "application/json",
        "X-MediaBrowser-Token": token
    }
    resp = requests.post(url, headers=headers)
    try:
        resp.raise_for_status()
        print(f"✔ Playlist {playlist_id} successfully refreshed (Admin).")
    except Exception as e:
        print(f"✗ Error refreshing playlist {playlist_id}: {e}")

# --- 4) DATABASE AND FILESYSTEM LOADERS ---------------------------------

def load_users():
    conn = sqlite3.connect(USERS_DB)
    cur  = conn.cursor()
    cur.execute("SELECT Username, Id FROM Users")
    mapping = { normalize_id(uid): name for name, uid in cur.fetchall() }
    conn.close()
    return mapping

def load_playlists():
    conn = sqlite3.connect(LIBRARY_DB)
    cur  = conn.cursor()
    cur.execute("""
        SELECT Name, PresentationUniqueKey
        FROM TypedBaseItems
        WHERE Type='MediaBrowser.Controller.Playlists.Playlist'
    """)
    mapping = { name: pid.lower() for name, pid in cur.fetchall() if pid }
    conn.close()
    return mapping

def load_owners():
    owners = {}
    for d in os.listdir(PLAYLISTS_DIR):
        xml_path = os.path.join(PLAYLISTS_DIR, d, "playlist.xml")
        if not os.path.isfile(xml_path):
            continue
        tree = ET.parse(xml_path)
        owner_id = tree.findtext("OwnerUserId")
        if owner_id:
            owners[d] = normalize_id(owner_id)
    return owners

# --- 5) LIST PERMISSIONS GROUPED BY OWNER -----------------------------

def list_permissions():
    """
    Group playlists by owner and print ASCII subtables.
    No authentication needed since it reads from disk/DB only.
    """
    users    = load_users()
    playlists = load_playlists()
    owners    = load_owners()

    print("\n=== Current Permissions ===\n")
    by_owner = {}
    for pname in playlists:
        owner_norm = owners.get(pname)
        if not owner_norm:
            continue
        by_owner.setdefault(owner_norm, []).append(pname)

    for owner_norm, pnames in by_owner.items():
        owner_name = users.get(owner_norm, "<unknown>")
        print(f"Owner: {owner_name} ({owner_norm})")
        rows = []
        for pname in sorted(pnames):
            xml_path = os.path.join(PLAYLISTS_DIR, pname, "playlist.xml")
            editors  = []
            if os.path.isfile(xml_path):
                root = ET.parse(xml_path).getroot()
                for share in root.findall("Shares/Share"):
                    if share.findtext("CanEdit") == "true":
                        uid_norm = normalize_id(share.findtext("UserId") or "")
                        if uid_norm != owner_norm:
                            editors.append(users.get(uid_norm, uid_norm))
            rows.append([pname, ", ".join(editors) or "-"])

        headers    = ["Playlist", "CanEdit by"]
        col_widths = [max(len(str(c)) for c in col) for col in zip(headers, *rows)]
        sep = "+" + "+".join("-"*(w+2) for w in col_widths) + "+"

        print(sep)
        print("| " + " | ".join(h.ljust(w) for h, w in zip(headers, col_widths)) + " |")
        print(sep)
        for row in rows:
            print("| " + " | ".join(str(c).ljust(w) for c, w in zip(row, col_widths)) + " |")
        print(sep + "\n")

# --- 6) MODIFY PERMISSION + AUTO-LIST ---------------------------------

def modify_permission(target_user, playlist, revoke=False, delay=5):
    users    = load_users()
    playlists = load_playlists()
    owners    = load_owners()

    if playlist not in playlists:
        raise SystemExit(f"Playlist '{playlist}' not found.")
    if target_user not in users.values():
        raise SystemExit(f"User '{target_user}' not found.")

    pid  = playlists[playlist]
    tuid  = next(u for u, n in users.items() if n == target_user)
    onorm = owners.get(playlist)
    if not onorm:
        raise SystemExit("Cannot determine playlist owner.")

    # 1) Owner authentication and grant/revoke
    owner_name  = users[onorm]
    print(f"Playlist '{playlist}' ({pid}) is owned by {owner_name}.")
    token_owner = authenticate(owner_name)

    headers = {
        "accept":              "application/json",
        "Content-Type":        "application/json",
        "X-MediaBrowser-Token": token_owner
    }
    url  = f"{JELLYFIN_BASE}/Playlists/{pid}/Users/{tuid}"
    body = {"UserId": tuid, "CanEdit": not revoke}
    resp = requests.post(url, headers=headers, json=body)
    action = "revoked" if revoke else "granted"
    if resp.status_code in (200, 204):
        print(f"Edit permission {action} for '{target_user}'.")
    else:
        print(f"Error {resp.status_code}: {resp.text}")
        return

    # 2) Admin authentication and refresh single playlist
    print("Admin authentication to refresh playlist…")
    admin_user  = input("Admin username: ")
    admin_token = authenticate(admin_user)
    print(f"Triggering refresh for playlist '{playlist}' ({pid})…")
    refresh_playlist(pid, admin_token)

    # 2.b) Delay to allow DB to synchronize
    if delay > 0:
        print(f"Waiting {delay} seconds to allow DB update…")
        time.sleep(delay)

    # 3) Immediately show updated permissions
    list_permissions()

# --- 7) ENTRYPOINT AND ARG PARSING -------------------------------------

def main():
    parser = argparse.ArgumentParser(
        description="Manage Jellyfin playlist write permissions"
    )
    parser.add_argument("user", nargs="?", help="Target username")
    parser.add_argument("playlist", nargs="?", help="Playlist name")
    parser.add_argument("--revoke", action="store_true", help="Revoke edit permission")
    parser.add_argument("--list", action="store_true",
                        help="List permissions grouped by owner")
    parser.add_argument("--delay", type=int, default=5,
                        help="Seconds to wait after refresh (default: 5)")
    args = parser.parse_args()

    if args.list:
        list_permissions()
    elif args.user and args.playlist:
        modify_permission(args.user, args.playlist,
                          revoke=args.revoke,
                          delay=args.delay)
    else:
        parser.print_help()

if __name__ == "__main__":
    main()

HOW TO USE: (it needs sudo only because it needs to access to the jellyfin database so or you run it as the jellyfin user or you run it with sudo)
sudo ~/JFPllstAccssCntrll/bin/python3 ~/JFPllstAccssCntrll/JFPllstAccssCntrll.py

Code:
usage: JFPllstAccssCntrll.py [-h] [--revoke] [--list] [--delay DELAY] [user] [playlist]
Manage Jellyfin playlist write permissions
positional arguments:
  user           Target username
  playlist       Playlist name
options:
  -h, --help     show this help message and exit
  --revoke       Revoke edit permission
  --list         List permissions grouped by owner
  --delay DELAY  Seconds to wait after refresh (default: 5)


sudo ~/JFPllstAccssCntrll/bin/python3 ~/JFPllstAccssCntrll/JFPllstAccssCntrll.py --list 
give you a list off all the playlist divided by owner and with a column that say who can edit the playlist other than the owner. It will ask the username and the password of an Admin

sudo ~/JFPllstAccssCntrll/bin/python3 ~/JFPllstAccssCntrll/JFPllstAccssCntrll.py [USER_TO_GRANT_PERMISSION] [PLAYLIST_NAME] 
to grant permission. It will ask the password of the owner of the playlist.

sudo ~/JFPllstAccssCntrll/bin/python3 ~/JFPllstAccssCntrll/JFPllstAccssCntrll.py [USER_TO_REVOKE_PERMISSION] [PLAYLIST_NAME] --revoke 
to revoke permission. It will ask the password of the owner of the playlist.

Now you have a shared playlist that can be changed from the users you granted permission.

If you want to hide this playlist to specific users I suggest you to add a tag to the playlist (for example "playlist_user01") and then in "settings/user/[USERNAME]/parentalcontrol/block/Locking Items with tag" add playlist_user01 for each user you want to hide the playlist.
This script helps grant or revoke playlist editing permissions in Jellyfin via API, more convenient and secure than manually editing files.