Jump to content

Interactive Duplicate Actor/Person Merger


Recommended Posts

Posted

Hi everyone,

Like many of you, I have run into the issue where Emby creates duplicate actor or director profiles (usually due to different metadata providers or slight spelling variations). Merging these manually through the web interface is pretty tedious, especially if an actor has multiple duplicate IDs.

To fix this, I asked Google Gemini to help me build a Windows tool for merging duplicate Actor/Person profiles automatically. I have compiled it into an easy-to-use .exe file.

 

How to use it:

1. Set up your config file

Before running the tool, you need to add your server details. Open the config.json file and fill in your information exactly like this (make sure to include http://):

{
    "EMBY_URL": "http://192.168.30.248:8096",
    "API_KEY": "abcdefghijklmnopqrstuvwxyz1234567"
}

 

2. Run the tool

  • Double-click emby_actor_merge_manually.exe.

  • Input the name of the duplicate actor or director you want to fix and press Enter.

  • The tool will search your server and list all profiles matching that name. Type the ID of the one you want to keep and press Enter.

  • Press Enter one last time to confirm the merge.

 

3. Cleanup Once the tool finishes moving everything over to the main profile, just go to your Emby Scheduled Tasks and run a Scan Media Library. Emby will automatically delete the leftover empty ghost profiles.

 

Source Code

Lastly, here is the source code of the .exe file written in Python, in case anyone prefers to run it manually as a script.

Hopefully, Emby developer will implement a similar built-in function for users to do this easily as an Admin in the future🙏

import requests
import urllib.parse
import json
import os
import sys

# ==========================================
# CONFIGURATION LOADER
# ==========================================
CONFIG_FILE = "config.json"

def load_config():
    if not os.path.exists(CONFIG_FILE):
        default_config = {
            "EMBY_URL": "http://YOUR_SERVER_IP:8096",
            "API_KEY": "YOUR_API_KEY_HERE"
        }
        with open(CONFIG_FILE, 'w') as f:
            json.dump(default_config, f, indent=4)
        print(f"[{CONFIG_FILE} not found!]")
        print(f"I just created a blank '{CONFIG_FILE}' in this folder.")
        print("Please open it, paste your Emby URL and API Key, save it, and run this script again.")
        sys.exit()

    with open(CONFIG_FILE, 'r') as f:
        try:
            config = json.load(f)
            return config.get("EMBY_URL", "").strip('/'), config.get("API_KEY", "")
        except json.JSONDecodeError:
            print(f"Error: {CONFIG_FILE} is not formatted properly.")
            sys.exit()

EMBY_URL, API_KEY = load_config()

if not EMBY_URL or not API_KEY or "YOUR_API_KEY_HERE" in API_KEY:
    print(f"Error: Please add your actual Emby URL and API Key to {CONFIG_FILE}.")
    sys.exit()
# ==========================================

headers = {
    "X-Emby-Token": API_KEY,
    "Content-Type": "application/json",
    "Accept": "application/json"
}

def main():
    search_name = input("Enter the name of the actor/director to search for: ").strip()
    if not search_name:
        print("Name cannot be empty. Exiting...")
        return

    print("\nAuthenticating and locating Admin User ID...")
    
    res_users = requests.get(f"{EMBY_URL}/emby/Users", headers=headers)
    if res_users.status_code != 200:
        print(f"Error fetching users. Status: {res_users.status_code}")
        return
        
    users = res_users.json()
    admin_id = None
    
    for u in users:
        if u.get("Policy", {}).get("IsAdministrator"):
            admin_id = u["Id"]
            break
            
    if not admin_id:
        print("Could not find an Admin user. Falling back to the first user.")
        admin_id = users[0]["Id"]

    print(f" -> Success! Using Admin ID: {admin_id}")
    print(f"\nSearching for profiles matching '{search_name}'...")
    
    safe_search_name = urllib.parse.quote(search_name)
    search_url = f"{EMBY_URL}/emby/Users/{admin_id}/Items?SearchTerm={safe_search_name}&IncludeItemTypes=Person&Recursive=true"
    
    res_search = requests.get(search_url, headers=headers)
    if res_search.status_code != 200:
        print(f"Error searching for actor. Status: {res_search.status_code}")
        return
        
    items = res_search.json().get("Items", [])
    
    if not items:
        print(f"No profiles found matching '{search_name}'. Exiting...")
        return

    found_profiles = {}
    for item in items:
        person_id = item.get("Id")
        if person_id:
            found_profiles[person_id] = item.get("Name", "Unknown")

    if len(found_profiles) == 1:
        print(f"\nOnly 1 profile found for '{search_name}'. No duplicates exist! Exiting...")
        return

    print(f"\nFound {len(found_profiles)} matching profiles:\n")
    print("=======================================================")
    for pid, pname in found_profiles.items():
        print(f" ID: {pid}  |  Name: {pname}")
    print("=======================================================\n")

    keep_id = input("Enter the ID of the profile you want to KEEP: ").strip()
    
    if keep_id not in found_profiles:
        print(f"Error: '{keep_id}' is not in the list of found profiles. Exiting...")
        return

    keep_name = found_profiles[keep_id]
    duplicates_to_merge = [pid for pid in found_profiles.keys() if pid != keep_id]
    
    print(f"\n[Settings Locked]")
    print(f" -> KEEPING:   {keep_name} ({keep_id})")
    print(f" -> MERGING:   {len(duplicates_to_merge)} duplicate(s) into the kept profile.")
    
    confirm = input("Press ENTER to begin the merge, or type 'Q' to quit: ")
    if confirm.strip().lower() == 'q':
        print("Canceled. Exiting...")
        return

    for duplicate_id in duplicates_to_merge:
        duplicate_name = found_profiles[duplicate_id]
        print(f"\n=======================================================")
        print(f" Hunting down media for: {duplicate_name} (ID: {duplicate_id})")
        print(f"=======================================================")
        
        dup_search_url = f"{EMBY_URL}/emby/Users/{admin_id}/Items?PersonIds={duplicate_id}&Recursive=true&Fields=People"
        res_dup = requests.get(dup_search_url, headers=headers)
        
        if res_dup.status_code != 200:
            print(f"Error fetching items for {duplicate_id}. Status: {res_dup.status_code}")
            continue
            
        dup_items = res_dup.json().get("Items", [])
        print(f"Found {len(dup_items)} items containing this duplicate.")

        if not dup_items:
            print("No merging needed. This duplicate has no attached media.")
            continue

        for item in dup_items:
            item_id = item["Id"]
            item_name = item.get("Name", "Unknown")
            print(f"\nProcessing: {item_name} (ID: {item_id})")

            item_get_url = f"{EMBY_URL}/emby/Users/{admin_id}/Items/{item_id}"
            res_item = requests.get(item_get_url, headers=headers)
            
            if res_item.status_code != 200:
                print(f" -> Failed to fetch full metadata. Error {res_item.status_code}")
                continue
                
            full_item = res_item.json()
            people = full_item.get("People", [])
            
            keep_exists = any(str(p.get("Id")) == str(keep_id) for p in people)
            new_people = []
            changed = False

            for p in people:
                if str(p.get("Id")) == str(duplicate_id):
                    if keep_exists:
                        print(f" -> Dropping duplicate (Main person already exists here).")
                        changed = True
                        continue
                    else:
                        print(f" -> Replacing duplicate with '{keep_name}'...")
                        p["Id"] = keep_id
                        p["Name"] = keep_name
                        changed = True
                
                new_people.append(p)

            if changed:
                full_item["People"] = new_people
                update_url = f"{EMBY_URL}/emby/Items/{item_id}"
                update_res = requests.post(update_url, headers=headers, json=full_item)
                
                if update_res.status_code in [200, 204]:
                    print(f" -> Successfully updated metadata!")
                else:
                    print(f" -> Failed to update. Error {update_res.status_code}")
            else:
                print(" -> No changes needed.")

    print("\nMerge complete! Run 'Scan Media Library' in Emby to wipe the leftover duplicate profiles.")

if __name__ == "__main__":
    main()

 

emby_actor_merge_manually.exe config.json

Posted

Hello noobilly,

** This is an auto reply **

Please wait for someone from staff support or our members to reply to you.

It's recommended to provide more info, as it explain in this thread:


Thank you.

Emby Team

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!

Register a new account

Sign in

Already have an account? Sign in here.

Sign In Now
×
×
  • Create New...