Files
HK-IT-Docs/Emby刷新演员头像脚本.md

30 KiB
Raw Permalink Blame History

Emby刷新演员头像脚本

1. 脚本总结

  • 用 PersonId 直接请求图片 → 触发Emby下载,不用模拟点击
  • 智能生成搜索关键词 → 中文取前1-2个字,英文取前2个字母
  • 进度持久化 → 中断后可以继续,不会重复处理
  • 限流保护 → 429/403/网络500自动停止
  • 只处理有 Primary 标记的 → 避免无效请求
  • 适用范围: Emby 4.8.x / 4.9.x 通用
  • 需要科学上网的环境(脚本走系统代理)
  • 大批量补全演员头像

说明: 整个补齐脚本分为两个阶段:
第一阶段: 补齐打开影片页就可以自动刷新的演员头像;
第二阶段: 补齐需要打开演员详情页才能刷新的演员头像.
脚本需要使用python,请自行安装。

2. 第一阶段脚本

import requests
import time
import json
import os
import sys
import re

# =========================
# 你需要修改的配置
# =========================

EMBY_URL = ""
API_KEY = ""
USER_ID = ""

# 每个搜索词之间等待多少秒
SEARCH_TERM_DELAY_SECONDS = 5

# 同一个搜索词下,每个演员头像之间等待多少秒
IMAGE_DELAY_SECONDS = 4

# 每个搜索词最多请求多少个带头像标记的演员
# 先保守一点,避免一个关键词返回太多人
MAX_IMAGES_PER_TERM = 50

# 每次搜索最多返回多少个演员
SEARCH_LIMIT = 200

# 读取媒体库分页间隔
PAGE_DELAY_SECONDS = 1

# 进度文件:记录已经搜索过的关键词、已经处理过的人物头像
PROGRESS_FILE = r"D:\emby_person_image_warmer_progress.json"

# 只从媒体库中收集演员
TARGET_PERSON_TYPES = {"Actor"}


# =========================
# 以下一般不用修改
# =========================

session = requests.Session()
session.headers.update({
    "X-Emby-Token": API_KEY,
    "User-Agent": "Mozilla/5.0 Emby-Web-Person-Image-Warmer/1.0"
})


def load_progress():
    if not os.path.exists(PROGRESS_FILE):
        return {
            "terms": {},
            "persons": {}
        }

    try:
        with open(PROGRESS_FILE, "r", encoding="utf-8") as f:
            data = json.load(f)

        if "terms" not in data:
            data["terms"] = {}
        if "persons" not in data:
            data["persons"] = {}

        return data

    except Exception:
        return {
            "terms": {},
            "persons": {}
        }


def save_progress(progress):
    with open(PROGRESS_FILE, "w", encoding="utf-8") as f:
        json.dump(progress, f, ensure_ascii=False, indent=2)


def wait_seconds(seconds, reason):
    print(f"{reason},等待 {seconds} 秒...")
    time.sleep(seconds)


def get_all_media_items():
    """
    获取所有电影和电视剧,用来收集演员名字。
    这里只读取 People,不刷新元数据,不替换图片。
    """
    items = []
    start = 0
    limit = 100

    while True:
        r = session.get(
            f"{EMBY_URL}/emby/Users/{USER_ID}/Items",
            params={
                "UserId": USER_ID,
                "Recursive": "true",
                "IncludeItemTypes": "Movie,Series",
                "Fields": "People",
                "StartIndex": start,
                "Limit": limit
            },
            timeout=60
        )
        r.raise_for_status()

        data = r.json()
        batch = data.get("Items", [])
        items.extend(batch)

        print(f"已读取媒体条目:{len(items)} / {data.get('TotalRecordCount', '?')}")

        if len(batch) < limit:
            break

        start += limit
        wait_seconds(PAGE_DELAY_SECONDS, "读取下一批媒体前")

    return items


def collect_actor_names(items):
    """
    从媒体条目中收集演员名字。
    """
    names = set()

    for item in items:
        for p in item.get("People", []) or []:
            if p.get("Type") not in TARGET_PERSON_TYPES:
                continue

            name = p.get("Name")
            if not name:
                continue

            name = name.strip()
            if name:
                names.add(name)

    return sorted(names)


def is_cjk_char(ch):
    """
    判断是否中日韩字符。
    """
    return (
        "\u4e00" <= ch <= "\u9fff" or
        "\u3040" <= ch <= "\u30ff" or
        "\uac00" <= ch <= "\ud7af"
    )


def make_search_terms_from_name(name):
    """
    从演员名字生成搜索关键词。

    例:
    刘若英 -> 刘, 刘若
    刘诗诗 -> 刘, 刘诗
    Li Ying -> li, yi
    Liu Shishi -> li, sh
    Aoi Tsukasa -> ao, ts
    葵司 -> 葵, 葵司
    """
    terms = set()
    clean = name.strip()

    if not clean:
        return terms

    # 中日韩名字:取第一个字、前两个字
    cjk_chars = [ch for ch in clean if is_cjk_char(ch)]

    if cjk_chars:
        terms.add(cjk_chars[0])

        if len(cjk_chars) >= 2:
            terms.add("".join(cjk_chars[:2]))

    # 拉丁字母名字:每个单词取前两个字母
    words = re.findall(r"[A-Za-zÀ-ÖØ-öø-ÿ]+", clean)

    for w in words:
        w = w.lower()

        if len(w) >= 2:
            terms.add(w[:2])
        elif len(w) == 1:
            terms.add(w)

    return terms


def build_search_terms(actor_names):
    """
    根据所有演员名字生成搜索词列表。
    """
    terms = set()

    for name in actor_names:
        terms.update(make_search_terms_from_name(name))

    # 去掉太短且可能结果太杂的英文单字母
    cleaned = set()
    for t in terms:
        t = t.strip()
        if not t:
            continue

        # 英文单字母容易返回太多,跳过
        if re.fullmatch(r"[a-zA-Z]", t):
            continue

        cleaned.add(t)

    return sorted(cleaned)


def search_persons(term):
    """
    模拟 Emby 搜索演员。
    """
    try:
        r = session.get(
            f"{EMBY_URL}/emby/Users/{USER_ID}/Items",
            params={
                "UserId": USER_ID,
                "Recursive": "true",
                "IncludeItemTypes": "Person",
                "SearchTerm": term,
                "Fields": "ImageTags,PrimaryImageAspectRatio,ProviderIds",
                "Limit": SEARCH_LIMIT
            },
            timeout=90
        )

        if r.status_code == 200:
            data = r.json()
            items = data.get("Items", [])
            total = data.get("TotalRecordCount", len(items))

            print(f"[搜索OK] 关键词={term} | 返回={len(items)} / 总数={total}")
            return "ok", items, total

        if r.status_code == 429:
            retry_after = r.headers.get("Retry-After")
            print(f"[429 限流] 关键词={term}")

            if retry_after:
                print(f"服务端要求等待 {retry_after} 秒。现在停止脚本。")
            else:
                print("检测到限流。现在停止脚本,建议稍后再跑。")

            return "rate_limited", [], 0

        if r.status_code == 403:
            print(f"[403 禁止访问] 关键词={term}")
            print("检测到可能的封锁/权限问题。现在停止脚本。")
            return "blocked", [], 0

        if r.status_code == 500:
            print(f"[搜索500] 关键词={term} | {r.text[:200]}")
            return "server_error_500", [], 0

        print(f"[搜索{r.status_code}] 关键词={term} | {r.text[:200]}")
        return "fail", [], 0

    except requests.exceptions.Timeout:
        print(f"[搜索超时] 关键词={term}")
        return "timeout", [], 0

    except Exception as e:
        print(f"[搜索错误] 关键词={term}: {e}")
        return "fail", [], 0


def request_person_image(person):
    """
    请求搜索结果里带 ImageTags.Primary 的演员头像。

    这是关键:
    /emby/Items/{PersonId}/Images/Primary?tag=xxx

    不刷新元数据。
    不替换图片。
    不删除图片。
    """
    name = person.get("Name")
    person_id = str(person.get("Id"))
    image_tags = person.get("ImageTags") or {}
    primary_tag = image_tags.get("Primary")

    if not primary_tag:
        print(f"    [跳过无头像标记] {name} | PersonId={person_id}")
        return "skip_no_tag"

    url = f"{EMBY_URL}/emby/Items/{person_id}/Images/Primary"

    params = {
        "maxWidth": 400,
        "quality": 90,
        "tag": primary_tag
    }

    try:
        r = session.get(url, params=params, timeout=90)

        content_type = r.headers.get("Content-Type", "")

        if r.status_code == 200:
            print(f"    [图片OK] {name} | PersonId={person_id} | {len(r.content)} bytes | {content_type}")
            return "ok"

        if r.status_code == 404:
            print(f"    [图片404] {name} | PersonId={person_id}")
            return "not_found"

        if r.status_code == 429:
            print(f"    [429 限流] {name} | PersonId={person_id}")
            return "rate_limited"

        if r.status_code == 403:
            print(f"    [403 禁止访问] {name} | PersonId={person_id}")
            return "blocked"

        if r.status_code == 500:
            text = r.text[:300]
            print(f"    [图片500] {name} | PersonId={person_id} | {text}")

            # 网络/代理/上游超时类 500,不记为成功,后面可以重试
            lower_text = text.lower()
            if (
                "ssl connection" in lower_text or
                "previous timeout" in lower_text or
                "timeout" in lower_text or
                "connection" in lower_text
            ):
                return "network_500"

            # Emby内部空引用一类,记录但不终止
            if "object reference" in lower_text:
                return "server_error_500"

            return "server_error_500"

        print(f"    [图片{r.status_code}] {name} | PersonId={person_id} | {r.text[:200]}")
        return "fail"

    except requests.exceptions.Timeout:
        print(f"    [图片超时] {name} | PersonId={person_id}")
        return "timeout"

    except Exception as e:
        print(f"    [图片请求错误] {name} | PersonId={person_id} | {e}")
        return "fail"


if __name__ == "__main__":
    progress = load_progress()

    print("开始读取媒体库,用来收集演员名字...")
    items = get_all_media_items()

    print("\n开始收集演员名字...")
    actor_names = collect_actor_names(items)

    print(f"共收集到演员数量:{len(actor_names)}")

    search_terms = build_search_terms(actor_names)

    print(f"生成搜索关键词数量:{len(search_terms)}")
    print("前 50 个搜索关键词预览:")
    for t in search_terms[:50]:
        print("-", t)

    print("\n开始正式处理演员头像...\n")

    for term_index, term in enumerate(search_terms, 1):
        if term in progress["terms"]:
            old_status = progress["terms"][term].get("status")
            print(f"{term_index}/{len(search_terms)} 跳过关键词:{term} | 已处理状态={old_status}")
            continue

        print("=" * 70)
        print(f"{term_index}/{len(search_terms)} 搜索关键词:{term}")

        search_status, people, total = search_persons(term)

        if search_status in {"rate_limited", "blocked"}:
            save_progress(progress)
            print("\n检测到限流/封锁风险,脚本停止。")
            sys.exit(1)

        if search_status != "ok":
            print("本关键词搜索失败,不写入关键词进度,下次会重试。")
            wait_seconds(SEARCH_TERM_DELAY_SECONDS, "搜索词之间间隔")
            continue

        # 只处理带 Primary 图片标记的人
        people_with_primary = []
        skipped_no_tag = 0
        skipped_done = 0

        for p in people:
            person_id = str(p.get("Id"))
            image_tags = p.get("ImageTags") or {}
            primary_tag = image_tags.get("Primary")

            if not primary_tag:
                skipped_no_tag += 1
                continue

            # 已经处理过这个人物头像,跳过
            if person_id in progress["persons"]:
                skipped_done += 1
                continue

            people_with_primary.append(p)

        print(f"本关键词返回人物:{len(people)}")
        print(f"无头像标记跳过:{skipped_no_tag}")
        print(f"已处理人物跳过:{skipped_done}")
        print(f"本次需要请求头像:{len(people_with_primary)}")

        processed_count = 0
        ok_count = 0
        fail_count = 0
        network_500_count = 0
        server_500_count = 0

        for idx, person in enumerate(people_with_primary[:MAX_IMAGES_PER_TERM], 1):
            person_id = str(person.get("Id"))
            name = person.get("Name")

            print(f"  {idx}/{min(len(people_with_primary), MAX_IMAGES_PER_TERM)} 请求头像:{name} | PersonId={person_id}")

            result = request_person_image(person)
            processed_count += 1

            if result == "ok":
                ok_count += 1
                progress["persons"][person_id] = {
                    "name": name,
                    "status": "ok",
                    "term": term,
                    "time": time.strftime("%Y-%m-%d %H:%M:%S")
                }
                save_progress(progress)

            elif result in {"not_found", "skip_no_tag", "server_error_500"}:
                if result == "server_error_500":
                    server_500_count += 1
                else:
                    fail_count += 1

                # 这类不是网络临时问题,可以记录,避免反复请求
                progress["persons"][person_id] = {
                    "name": name,
                    "status": result,
                    "term": term,
                    "time": time.strftime("%Y-%m-%d %H:%M:%S")
                }
                save_progress(progress)

            elif result == "network_500":
                network_500_count += 1
                print("    检测到网络/SSL/timeout 类 500,本人物不写入进度,下次可重试。")

                # 连续/多次网络失败时,建议停止,不要继续刺激上游
                if network_500_count >= 3:
                    save_progress(progress)
                    print("\n本关键词内出现 3 次网络类 500,脚本停止。请确认代理/Emby网络后再运行。")
                    sys.exit(1)

            elif result in {"rate_limited", "blocked"}:
                save_progress(progress)
                print("\n检测到限流/封锁风险,脚本停止。")
                sys.exit(1)

            else:
                fail_count += 1
                print("    本次失败,不写入人物进度,下次会重试。")

            if idx < min(len(people_with_primary), MAX_IMAGES_PER_TERM):
                wait_seconds(IMAGE_DELAY_SECONDS, "同一关键词下演员头像之间间隔")

        # 这个关键词已经搜索并处理过一轮,记录关键词进度
        progress["terms"][term] = {
            "status": "done",
            "returned": len(people),
            "total": total,
            "processed_images": processed_count,
            "ok": ok_count,
            "network_500": network_500_count,
            "server_500": server_500_count,
            "time": time.strftime("%Y-%m-%d %H:%M:%S")
        }
        save_progress(progress)

        print(f"关键词 {term} 完成:图片OK={ok_count},网络500={network_500_count},服务端500={server_500_count}")

        if term_index < len(search_terms):
            wait_seconds(SEARCH_TERM_DELAY_SECONDS, "搜索词之间间隔")

    print("\n全部处理完成。")
    print(f"进度文件已保存:{PROGRESS_FILE}")

3. 第二阶段脚本

import requests
import time
import json
import os
import sys

# =========================
# 你需要修改的配置
# =========================

EMBY_URL = ""
API_KEY = ""
USER_ID = ""

# 第一阶段脚本的进度文件(如果存在,就会跳过已经成功的人)
PHASE1_PROGRESS_FILE = r"D:\emby_person_image_warmer_progress.json"

# 第二阶段自己的进度文件
PHASE2_PROGRESS_FILE = r"D:\emby_person_detail_warmer_phase2_progress.json"

# 每次拉取多少个人物
PAGE_LIMIT = 200

# 打开人物详情页后,等几秒再重新检查
DETAIL_RECHECK_DELAY_SECONDS = 2

# 每处理一个人物后等待几秒
PERSON_DELAY_SECONDS = 3

# 每处理多少个人物,休息一下
BATCH_SIZE = 100
BATCH_REST_SECONDS = 60


# =========================
# 以下一般不用修改
# =========================

session = requests.Session()
session.headers.update({
    "X-Emby-Token": API_KEY,
    "User-Agent": "Mozilla/5.0 Emby-Person-Detail-Warmer-Phase2/1.0"
})


def load_json_file(path, default_value):
    if not os.path.exists(path):
        return default_value

    try:
        with open(path, "r", encoding="utf-8") as f:
            return json.load(f)
    except Exception:
        return default_value


def save_json_file(path, data):
    with open(path, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)


def load_phase1_ok_persons():
    """
    读取第一阶段已成功的人物,避免重复处理。
    """
    data = load_json_file(PHASE1_PROGRESS_FILE, {"terms": {}, "persons": {}})
    ok_persons = set()

    persons = data.get("persons", {})
    for person_id, info in persons.items():
        if info.get("status") == "ok":
            ok_persons.add(str(person_id))

    return ok_persons


def load_phase2_progress():
    data = load_json_file(PHASE2_PROGRESS_FILE, {"persons": {}})
    if "persons" not in data:
        data["persons"] = {}
    return data


def save_phase2_progress(progress):
    save_json_file(PHASE2_PROGRESS_FILE, progress)


def wait_seconds(seconds, reason):
    print(f"{reason},等待 {seconds} 秒...")
    time.sleep(seconds)


def get_all_persons():
    """
    直接读取 Emby 全部 Person 条目。
    """
    persons = []
    start = 0

    while True:
        r = session.get(
            f"{EMBY_URL}/emby/Users/{USER_ID}/Items",
            params={
                "UserId": USER_ID,
                "Recursive": "true",
                "IncludeItemTypes": "Person",
                "Fields": "ImageTags,PrimaryImageAspectRatio,ProviderIds",
                "StartIndex": start,
                "Limit": PAGE_LIMIT
            },
            timeout=90
        )

        r.raise_for_status()

        data = r.json()
        batch = data.get("Items", [])
        total = data.get("TotalRecordCount", "?")

        persons.extend(batch)

        print(f"已读取人物:{len(persons)} / {total}")

        if len(batch) < PAGE_LIMIT:
            break

        start += PAGE_LIMIT
        time.sleep(0.5)

    return persons


def get_person_detail(person_id):
    """
    模拟打开人物详情页。
    """
    try:
        r = session.get(
            f"{EMBY_URL}/emby/Users/{USER_ID}/Items/{person_id}",
            params={
                "Fields": "ProviderIds,ImageTags,PrimaryImageAspectRatio,Overview"
            },
            timeout=90
        )

        if r.status_code == 200:
            return "ok", r.json()

        if r.status_code == 429:
            return "rate_limited", None

        if r.status_code == 403:
            return "blocked", None

        if r.status_code == 500:
            text = r.text[:300].lower()
            if (
                "ssl connection" in text or
                "previous timeout" in text or
                "timeout" in text or
                "connection" in text
            ):
                return "network_500", None
            return "server_error_500", None

        return "fail", None

    except requests.exceptions.Timeout:
        return "timeout", None

    except Exception:
        return "fail", None


def request_person_image(person_id, name, primary_tag):
    """
    请求人物头像:
    /emby/Items/{PersonId}/Images/Primary?tag=xxx
    """
    try:
        r = session.get(
            f"{EMBY_URL}/emby/Items/{person_id}/Images/Primary",
            params={
                "maxWidth": 400,
                "quality": 90,
                "tag": primary_tag
            },
            timeout=90
        )

        if r.status_code == 200:
            print(f"    [图片OK] {name} | PersonId={person_id} | {len(r.content)} bytes")
            return "ok"

        if r.status_code == 404:
            print(f"    [图片404] {name} | PersonId={person_id}")
            return "not_found"

        if r.status_code == 429:
            print(f"    [429 限流] {name} | PersonId={person_id}")
            return "rate_limited"

        if r.status_code == 403:
            print(f"    [403 禁止访问] {name} | PersonId={person_id}")
            return "blocked"

        if r.status_code == 500:
            text = r.text[:300]
            print(f"    [图片500] {name} | PersonId={person_id} | {text}")

            lower_text = text.lower()
            if (
                "ssl connection" in lower_text or
                "previous timeout" in lower_text or
                "timeout" in lower_text or
                "connection" in lower_text
            ):
                return "network_500"

            if "object reference" in lower_text:
                return "server_error_500"

            return "server_error_500"

        print(f"    [图片{r.status_code}] {name} | PersonId={person_id} | {r.text[:200]}")
        return "fail"

    except requests.exceptions.Timeout:
        print(f"    [图片超时] {name} | PersonId={person_id}")
        return "timeout"

    except Exception as e:
        print(f"    [图片请求错误] {name} | PersonId={person_id} | {e}")
        return "fail"


if __name__ == "__main__":
    print("当前运行目录:", os.getcwd())
    print("第一阶段进度文件:", PHASE1_PROGRESS_FILE)
    print("第二阶段进度文件:", PHASE2_PROGRESS_FILE)

    phase1_ok_persons = load_phase1_ok_persons()
    phase2_progress = load_phase2_progress()

    print(f"\n第一阶段已成功人物数量:{len(phase1_ok_persons)}")

    print("\n开始读取 Emby 全部人物...")
    persons = get_all_persons()

    print(f"\n共读取到人物数量:{len(persons)}")

    need_process = []
    skipped_phase1_ok = 0
    skipped_phase2_done = 0
    skipped_already_has_primary = 0

    for p in persons:
        person_id = str(p.get("Id"))
        name = p.get("Name", "")
        image_tags = p.get("ImageTags") or {}
        primary_tag = image_tags.get("Primary")

        # 第一阶段已经成功刷过
        if person_id in phase1_ok_persons:
            skipped_phase1_ok += 1
            continue

        # 第二阶段已经处理过
        if person_id in phase2_progress["persons"]:
            skipped_phase2_done += 1
            continue

        # 当前列表里已经有头像标记的,也先跳过
        # 因为第二阶段目标就是补“没有头像标记但点详情会出来”的人
        if primary_tag:
            skipped_already_has_primary += 1
            continue

        need_process.append({
            "Id": person_id,
            "Name": name
        })

    print(f"第一阶段已成功,跳过:{skipped_phase1_ok}")
    print(f"第二阶段已处理,跳过:{skipped_phase2_done}")
    print(f"当前本身已有头像标记,跳过:{skipped_already_has_primary}")
    print(f"第二阶段需要处理:{len(need_process)}\n")

    processed = 0
    detail_success_count = 0
    image_ok_count = 0
    still_no_primary_count = 0
    network_500_count = 0
    server_500_count = 0
    fail_count = 0

    for idx, person in enumerate(need_process, 1):
        person_id = person["Id"]
        name = person["Name"]

        print("=" * 70)
        print(f"{idx}/{len(need_process)} 处理人物:{name} | PersonId={person_id}")

        # 第一次打开人物详情
        status1, detail1 = get_person_detail(person_id)

        if status1 in {"rate_limited", "blocked"}:
            save_phase2_progress(phase2_progress)
            print("\n检测到限流/封锁风险,脚本停止。")
            sys.exit(1)

        if status1 == "network_500":
            network_500_count += 1
            print("  打开人物详情时发生网络类 500,本次不写进度,下次重试。")
            if network_500_count >= 3:
                save_phase2_progress(phase2_progress)
                print("\n连续出现 3 次网络类 500,脚本停止。请确认代理/Emby 后再运行。")
                sys.exit(1)

            if idx < len(need_process):
                wait_seconds(PERSON_DELAY_SECONDS, "人物之间间隔")
            continue

        if status1 == "server_error_500":
            server_500_count += 1
            print("  打开人物详情时发生服务端 500,记录后跳过。")
            phase2_progress["persons"][person_id] = {
                "name": name,
                "status": "server_error_500_on_detail",
                "time": time.strftime("%Y-%m-%d %H:%M:%S")
            }
            save_phase2_progress(phase2_progress)

            if idx < len(need_process):
                wait_seconds(PERSON_DELAY_SECONDS, "人物之间间隔")
            continue

        if status1 != "ok":
            fail_count += 1
            print("  打开人物详情失败,本次不写进度,下次重试。")

            if idx < len(need_process):
                wait_seconds(PERSON_DELAY_SECONDS, "人物之间间隔")
            continue

        detail_success_count += 1

        # 打开详情页后,等一会儿再重新读取一次详情
        wait_seconds(DETAIL_RECHECK_DELAY_SECONDS, "已打开人物详情,重新检查头像标记前")

        status2, detail2 = get_person_detail(person_id)

        if status2 in {"rate_limited", "blocked"}:
            save_phase2_progress(phase2_progress)
            print("\n检测到限流/封锁风险,脚本停止。")
            sys.exit(1)

        if status2 == "network_500":
            network_500_count += 1
            print("  二次读取人物详情时发生网络类 500,本次不写进度,下次重试。")
            if network_500_count >= 3:
                save_phase2_progress(phase2_progress)
                print("\n连续出现 3 次网络类 500,脚本停止。请确认代理/Emby 后再运行。")
                sys.exit(1)

            if idx < len(need_process):
                wait_seconds(PERSON_DELAY_SECONDS, "人物之间间隔")
            continue

        if status2 == "server_error_500":
            server_500_count += 1
            print("  二次读取人物详情时发生服务端 500,记录后跳过。")
            phase2_progress["persons"][person_id] = {
                "name": name,
                "status": "server_error_500_on_recheck",
                "time": time.strftime("%Y-%m-%d %H:%M:%S")
            }
            save_phase2_progress(phase2_progress)

            if idx < len(need_process):
                wait_seconds(PERSON_DELAY_SECONDS, "人物之间间隔")
            continue

        if status2 != "ok":
            fail_count += 1
            print("  二次读取人物详情失败,本次不写进度,下次重试。")

            if idx < len(need_process):
                wait_seconds(PERSON_DELAY_SECONDS, "人物之间间隔")
            continue

        image_tags = detail2.get("ImageTags") or {}
        primary_tag = image_tags.get("Primary")

        if not primary_tag:
            still_no_primary_count += 1
            print("  打开人物详情后仍然没有头像标记,记录为 still_no_primary。")

            phase2_progress["persons"][person_id] = {
                "name": name,
                "status": "still_no_primary",
                "time": time.strftime("%Y-%m-%d %H:%M:%S")
            }
            save_phase2_progress(phase2_progress)

            if idx < len(need_process):
                wait_seconds(PERSON_DELAY_SECONDS, "人物之间间隔")
            continue

        # 已经拿到头像标记,正式请求头像
        print(f"  详情补全成功,发现头像标记:{primary_tag}")
        image_result = request_person_image(person_id, name, primary_tag)

        if image_result == "ok":
            image_ok_count += 1
            phase2_progress["persons"][person_id] = {
                "name": name,
                "status": "ok",
                "primary_tag": primary_tag,
                "time": time.strftime("%Y-%m-%d %H:%M:%S")
            }
            save_phase2_progress(phase2_progress)

        elif image_result == "network_500":
            network_500_count += 1
            print("  请求头像时发生网络类 500,不写进度,下次重试。")
            if network_500_count >= 3:
                save_phase2_progress(phase2_progress)
                print("\n连续出现 3 次网络类 500,脚本停止。请确认代理/Emby 后再运行。")
                sys.exit(1)

        elif image_result == "server_error_500":
            server_500_count += 1
            phase2_progress["persons"][person_id] = {
                "name": name,
                "status": "server_error_500_on_image",
                "primary_tag": primary_tag,
                "time": time.strftime("%Y-%m-%d %H:%M:%S")
            }
            save_phase2_progress(phase2_progress)

        elif image_result in {"not_found"}:
            phase2_progress["persons"][person_id] = {
                "name": name,
                "status": image_result,
                "primary_tag": primary_tag,
                "time": time.strftime("%Y-%m-%d %H:%M:%S")
            }
            save_phase2_progress(phase2_progress)

        elif image_result in {"rate_limited", "blocked"}:
            save_phase2_progress(phase2_progress)
            print("\n检测到限流/封锁风险,脚本停止。")
            sys.exit(1)

        else:
            fail_count += 1
            print("  请求头像失败,本次不写进度,下次重试。")

        processed += 1

        if idx < len(need_process):
            wait_seconds(PERSON_DELAY_SECONDS, "人物之间间隔")

        if processed > 0 and processed % BATCH_SIZE == 0:
            save_phase2_progress(phase2_progress)
            wait_seconds(BATCH_REST_SECONDS, f"已处理 {processed} 个人物,本批休息")

    save_phase2_progress(phase2_progress)

    print("\n全部处理完成。")
    print(f"成功打开详情:{detail_success_count}")
    print(f"成功刷出头像:{image_ok_count}")
    print(f"打开详情后仍无头像标记:{still_no_primary_count}")
    print(f"网络类500{network_500_count}")
    print(f"服务端500{server_500_count}")
    print(f"其他失败:{fail_count}")
    print(f"第二阶段进度文件:{PHASE2_PROGRESS_FILE}")