HT5 Interface technical guide

How to read and change a Zidoo player's Home Theater (HT5) library over its HTTP API: what each endpoint does, the parameters it really expects, and the behavior you need to plan for.

Guide version: 1.1.2 Updated: October 3, 2026 Covers: HT 5.0.68 – 5.1.08, on Z9X Pro and Z9X 8K players z-keeper.com
About this guide. Zidoo publishes little official documentation for the Home Theater interface. This guide is our best interpretation of how it works, built from a significant amount of time testing each endpoint through many different scenarios on real players.
  • Behavior can change: any HT app or Zidoo firmware release can change what's documented here.
  • No guarantee: we don't promise any of it is accurate, and it isn't reviewed or approved by Zidoo.
  • Kept up to date: it's the best we can offer anyone building software that works with a Zidoo, and we'll update it as we discover new things (see the Changelog).

Introduction

Official documentation

LinkCovers
Zidoo Developer Platform: FilesFile Control API: storage devices and folder listings
Zidoo Developer Platform: MoviesThe poster/movie library API

The official pages cover only part of the API, and some details in them differ from how current players behave; those differences are noted under each endpoint. Most endpoints in this guide are not in the official pages.

Requests & responses

Read the status in the body

HTTP 200 only means the request reached the player. The outcome is the JSON status field, and on newer endpoints also code. Even a body status of 200 doesn't prove a classic write took effect: several classic writes answer {"status":200,"msg":"success"} and change nothing. Read back after every write.

Body statusMeaning
200OK (for classic writes, "accepted", not "done")
804A catch-all. It can mean any of these:
  • the route doesn't exist on this HT build;
  • the parameters are the wrong shape;
  • the player is busy;
  • the call came just after a write (see Recommended practices);
  • the ID is of a kind the route can't serve;
  • a write was refused because the HT app isn't running.
Retry once after a pause before treating it as final.
803"Data empty or incorrect": the entry exists but this route has nothing to serve for it
805A required parameter is missing (File Control type)
New endpoints{"status":N,"code":"…"}: 200 OK, 400 INVALID_ARGUMENT, 404 NOT_FOUND, 409 LIBRARY_BUSY (a library scan is running), 500 OPERATION_FAILED; also WRONG_ENTRY_TYPE, METADATA_UNAVAILABLE, VERIFY_FAILED.

A few routes answer in JSONP (callback({...})); strip the wrapper before parsing. Error bodies are small objects that can deserialize into an empty record, so treat a record with no positive id as "no answer".

Entry types & IDs

Entry types

typeEntry
0A file. Every movie and episode has one or more file entries beneath it. Unmatched files are also type 0.
1Movie
2Collection created by scraping (a TMDB set). Members point to it with parentId. It dissolves when a removal leaves one member.
3Series
4Season. A show with a single season is listed as its season.
5Episode
6Collection created by the user. Members point to it with collectionId. It persists even when empty. The library's hidden "Unmatched" group is also a type-6 entry; don't modify it.

Which ID to send

Parameter names don't reliably say which ID they want. In particular, many parameters called aggregationId take the entry ID.

IDWhere to get itTaken by
Entry IDid on list rows, detail and edit recordsDetail and edit reads, metadata writes, favorite, lock, artwork writes and upload, adding to and removing from collections, all category calls
File IDedit record videos[].id (equal to detail aggregations[].id), or fileId from a path lookupsetPlayPoint, markAsWatched, per-file rematch
Collection IDgetCollectionListgetCollection, addToCollection, disbandCollection
Category IDgetAlbumsAll category calls
aggregationId field / VideoInfo IDList rows; detail aggregations[].aggregation.idAvoid. These are different namespaces whose numbers overlap with entry IDs.
IDs are local to one player and are not stable. Rebuilding or rescanning the library can renumber entries and reuse old IDs for different titles. Re-read IDs before writing (see Recommended practices). Only provider IDs (TMDB, TVDb, IMDb) and file paths identify the same title across players.

File paths

HT versions & feature detection

Detect the new endpoints by calling them, not by comparing versions. HT builds of the same versionCode have differed in behavior.

EndpointAvailable from
v2/setPlayPointHT 5.0.98; use versionCode 5099 or later (earlier 5098 builds expect a different ID)
v2/getEntry, v2/getIdByPathname, v2/getIdsByPathname, v2/setPathnameMatch, v2/changeMatchDirectHT 5.1.03 (versionCode 5102)
v2/getAlbumMembersThe HT build after 5.1.03

Read the installed HT version from /ZidooControlCenter/Apps/getApps (package com.zidoo.poster).

Recommended practices

  1. Read back every write. Confirm the change from a fresh read, not from the reply. Classic writes can answer success and do nothing, especially when the HT app isn't running.
  2. Pause after a write. The next call or two after a write may answer 804 or return an empty edit record. Wait a few seconds before reading back, and retry an 804 once.
  3. Space out writes.
    • Collections: about 350 ms apart.
    • Categories: drop writes even at 250 ms, so read back and re-send.
    • Artwork uploads: at least 1 s apart.
    • Rematch saves: refused when sent in quick succession; retry with a growing pause.
  4. Never write to a stored ID. Before writing, confirm the ID still names the same title: getEntry on HT 5.1.03+, otherwise the edit record. If it doesn't, find the title's current ID through its files' paths (getIdsByPathname).
  5. Pair titles across players by file path or provider ID, never by entry ID or name.
  6. Treat a failed read as unknown, not empty. List and collection reads occasionally answer 804 on an idle player. Reading that as "no members" leads to destructive writes.
  7. Make sure the HT app is running before writing (see The HT app & writes).

Using the examples

Every endpoint below has an Example you can expand. Each one shows a sample call, a real response from a player (trimmed, with names and addresses changed), what the call does, and how to use the result. While Z-Keeper is written in C# (WinUI), these examples are written in Python for simplicity and use one small helper:

import json, requests

BASE = "http://192.168.1.50:9529"          # your player's address

def call(path, **params):
    """GET an HT endpoint and return its JSON body.
    HTTP 200 only means the player answered: check the body's status/code yourself."""
    r = requests.get(BASE + path, params=params, timeout=30)
    r.raise_for_status()
    return r.json()

Responses are shortened with only the fields that matter shown. Entry IDs, paths and titles are samples; yours will differ.

Device, apps & power

CLASSICGET /ZidooControlCenter/getModel
  • Returns: status, model, firmware, ip, net_mac (Ethernet), wif_mac, duuid, and capability flags such as ableRemoteSleep. net_mac and duuid identify the player.
  • Answers whenever the player is on, even when the HT app is closed, so it doesn't show that library writes will work.
Example

Call

info = call("/ZidooControlCenter/getModel")

Response

{
  "status": 200,
  "model": "Z9X 8K",
  "firmware": "v1.3.45",
  "ip": "192.168.1.50",
  "net_mac": "80:0a:80:5f:10:47",
  "wif_mac": "80:9d:65:51:e0:80",
  "duuid": "80:0a:80:5f:10:47",
  "androidversion": "11",
  "ableRemoteBoot": true,
  "ableRemoteShutdown": true,
  "ableRemoteSleep": false
}

What it does

Identifies the player. Store net_mac (or duuid) as its permanent ID, since the IP address can change, and keep net_mac for Wake-on-LAN. A 200 here only means the player is on, not that the HT app is running.

Using the result

player_id = info["duuid"]
wake_mac = info["net_mac"]          # Ethernet MAC - the one Wake-on-LAN needs
print(f'{info["model"]} firmware {info["firmware"]}')
CLASSICGET /ZidooControlCenter/Apps/getApps
  • Returns: apps[{label, packageName, versionName, versionCode}]. The HT app is com.zidoo.poster.
Example

Call

apps = call("/ZidooControlCenter/Apps/getApps")

Response

{
  "status": 200,
  "apps": [
    { "label": "Home Theater 5.0", "packageName": "com.zidoo.poster",
      "versionName": "5.1.08", "versionCode": 5108, "isSystemApp": false },
    { "label": "ApkInstaller", "packageName": "com.zidoo.usb.install",
      "versionName": "1.3.8", "versionCode": 38, "isSystemApp": true }
  ]
}

What it does

Lists the installed apps. Find com.zidoo.poster to learn the HT version, for example to know whether setPlayPoint is safe to use (versionCode 5099 or later).

Using the result

ht = next(a for a in apps["apps"] if a["packageName"] == "com.zidoo.poster")
print("HT", ht["versionName"], ht["versionCode"])
can_set_resume_points = ht["versionCode"] >= 5099
CLASSICGET /ZidooControlCenter/Apps/openApp?packageName=com.zidoo.poster
  • Starts the HT app. Library writes are accepted 8–12 seconds later.
  • No endpoint closes the app or reports whether it is running.
Example

Call

call("/ZidooControlCenter/Apps/openApp", packageName="com.zidoo.poster")

Response

{ "status": 200 }

What it does

Starts the HT app on the TV (or brings it to the front). Writes are accepted about 8–12 seconds later. Call it before a batch of writes, and again if writes start answering 804 or doing nothing.

Using the result

import time
call("/ZidooControlCenter/Apps/openApp", packageName="com.zidoo.poster")
time.sleep(10)          # give the app time to start before writing
CLASSICGET /ZidooControlCenter/RemoteControl/sendkey?key=<key>
  • Key.PowerOn.Poweroff powers the player off. It stops answering within 2–6 s.
  • Key.PowerOn.Standby only blanks the screen: the player stays on and keeps answering.
  • Powering on needs Wake-on-LAN: send a magic packet to net_mac (broadcast, UDP ports 9 and 7). The player answers again after about 20–30 s.
Wake-on-LAN works only over Ethernet. A player on Wi-Fi ignores it.
Example

Call

call("/ZidooControlCenter/RemoteControl/sendkey", key="Key.PowerOn.Poweroff")

Response

{ "status": 200 }

What it does

Sends a remote-control key. Key.PowerOn.Poweroff turns the player off; it stops answering within a few seconds. To turn it back on, send a Wake-on-LAN packet; there is no HTTP call for that.

Using the result

import socket
def wake(mac):                         # mac = getModel's net_mac
    payload = bytes.fromhex("FF" * 6 + mac.replace(":", "") * 16)
    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
        s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
        for port in (9, 7):
            s.sendto(payload, ("255.255.255.255", port))
wake("80:0a:80:5f:10:47")              # the player answers again after ~20-30 s (Ethernet only)

File Control

These endpoints read the player's filesystem directly, so they also see files the HT library hasn't registered. That makes them useful for finding files a scan skipped.

CLASSICGET /ZidooFileControl/getDevices
  • Returns: devices[{name, path, type}]. Types: 1000 internal, 1001/1002 USB, 1003 SD card, 1004 NFS, 1005 SMB.
Example

Call

devs = call("/ZidooFileControl/getDevices")

Response

{
  "status": 200,
  "devices": [
    { "name": "Flash", "path": "/storage/emulated/0", "type": 1000 },
    { "name": "SMB",   "path": "/tmp/ramfs/mnt/",     "type": 1005 }
  ]
}

What it does

Lists the storage the player can see. Take the network entries (1004 NFS, 1005 SMB) and pass their path to getHost to reach the shares.

Using the result

smb = [d for d in devs["devices"] if d["type"] == 1005]
for d in smb:
    print(d["name"], d["path"])
CLASSICGET /ZidooFileControl/getHost?path=<mount>&type=1004|1005
  • Returns: hosts[{ip, name}]. Despite its name, ip holds the host's browsable root path (form-encoded).
Example

Call

hosts = call("/ZidooFileControl/getHost", path="/tmp/ramfs/mnt/", type=1005)

Response

{
  "status": 200,
  "hosts": [
    { "name": "NAS", "ip": "%2Ftmp%2Framfs%2Fmnt%2F192.168.1.10" }
  ]
}

What it does

Lists the hosts behind a network mount. ip is really a form-encoded path to the host's root, to pass on to getFileList.

Using the result

from urllib.parse import unquote_plus
roots = [unquote_plus(h["ip"]) for h in hosts["hosts"]]   # "/tmp/ramfs/mnt/192.168.1.10"
CLASSICGET /ZidooFileControl/getFileList?path=<encoded path>&type=<n>
  • Returns: {status, isExists, filelist[{name, path, type, length, modifyDate, isBDMV, isBluray}]}. Entry type: 0 folder, 2 video.
  • type is required; without it the answer is 805.
  • For an ordinary folder, type does not filter the listing; read each entry's own type.
  • For a network host's root, type=0 answers isExists:false; pass 1005 (SMB) or 1004 (NFS) to list its shares.
  • Address a share as <host path>#<share>.
  • Returned paths are form-encoded (%2F, + for space).
  • The mount prefix differs by firmware (/mnt/smb/…, /data/system/smb/…). Always start from getDevices/getHost; never hard-code it.
Example

Call

# list the shares on a host (type 1005 for SMB), then a folder inside one
shares = call("/ZidooFileControl/getFileList", path="/tmp/ramfs/mnt/192.168.1.10", type=1005)
folder = call("/ZidooFileControl/getFileList",
              path="/tmp/ramfs/mnt/192.168.1.10#Media/Movies", type=0)

Response

{
  "status": 200,
  "isExists": true,
  "filelist": [
    { "name": "The Matrix (1999)", "type": 0, "length": 0,
      "path": "%2Ftmp%2Framfs%2Fmnt%2F192.168.1.10%23Media%2FMovies%2FThe+Matrix+%281999%29",
      "modifyDate": 1696012345000, "isBDMV": false, "isBluray": false },
    { "name": "Heat (1995).mkv", "type": 2, "length": 31457280000,
      "path": "%2Ftmp%2Framfs%2Fmnt%2F192.168.1.10%23Media%2FMovies%2FHeat+%281995%29.mkv",
      "modifyDate": 1696012399000, "isBDMV": false, "isBluray": false }
  ]
}

What it does

Lists one folder. Entry type 0 is a folder and 2 a video. Paths come back form-encoded; decode them before showing or comparing. A Blu-ray folder structure is flagged with isBDMV.

Using the result

from urllib.parse import unquote_plus
for f in folder["filelist"]:
    kind = "folder" if f["type"] == 0 else "video"
    print(kind, f["name"], unquote_plus(f["path"]), f["length"])

Library listings

CLASSICGET /ZidooPoster/getVideoList?page=<n>&pagesize=<n>&type=0

Equivalent forms:

  • /Poster/v2/getFilterAggregations?page=&pagesize=&type=0 (rows under array);
  • /Poster/getVideoList?type=movie&page=&rows=;
  • /filter?source=-1&videoType=<v>&genre=-1&year=&sort=0&page=&count=, where videoType is −1 all, 0 movie, 1 TV, 2–4 other.
  • Returns: {status, page, pagesize, size, data[{id, parentId, collectionId, aggregationId, type, favor, lock, voteAverage, name, isBluRay, is3d, is4k, year}]}.
  • Lists the poster wall's top-level tiles: movies, collections, series and single-season shows. Movies inside a collection aren't listed here; read them with getCollection.
  • It is not a file list. There's no endpoint that maps every file to its title in one call. A file URI appears only on rows that are themselves files, so a missing URI doesn't mean the file is gone.
  • No resume points, watched dates or container artwork.
  • Fields vary between rows:
    • year can be a string, a number, or absent.
    • collectionId on list rows is often −1 (a filter value), not a collection.
    • A file row is occasionally listed as type 1; the edit record has the true type.
  • Paging:
    • Keep pagesize ≤ 2000. Paging is by offset, so a huge size skips everything after page 1.
    • While a library is indexing, HT returns short pages in the middle of the list, so continue until an empty page.
    • An empty page can be a transient blip: confirm it with two more reads ~400 ms apart.
    • Some players ignore page and repeat page 1, so also stop when a page brings no new IDs.
  • The whole list sometimes answers 804 while the player is busy; retry after 1, 2 and 3.5 s.

There is no listing filtered by watched or in-progress state, and no viewing-history endpoint.

Example

Call

page = call("/ZidooPoster/getVideoList", page=1, pagesize=500, type=0)

Response

{
  "status": 200, "page": 1, "pagesize": 500, "size": 500,
  "data": [
    { "id": 4210, "parentId": 2985, "collectionId": -1, "aggregationId": 851,
      "type": 1, "name": "The Matrix", "year": "1999", "favor": false,
      "lock": false, "voteAverage": 8.2, "isBluRay": false, "is3d": false, "is4k": true },
    { "id": 1830, "parentId": 1829, "collectionId": -1, "aggregationId": 35,
      "type": 4, "name": "Breaking Bad", "year": "2008", "favor": false,
      "lock": false, "voteAverage": 8.9, "isBluRay": false, "is3d": false, "is4k": false }
  ]
}

What it does

Reads the poster wall one page at a time. Keep paging until a page comes back empty, since short pages can appear mid-list while the library is indexing. Use type to tell movies, collections, series and seasons apart, then read each one's detail or members.

Using the result

def all_tiles(pagesize=500):
    seen, page = {}, 1
    while True:
        rows = call("/ZidooPoster/getVideoList", page=page, pagesize=pagesize, type=0).get("data") or []
        new = [r for r in rows if r["id"] not in seen]
        if not new:                     # empty page, or a player that repeats page 1
            return list(seen.values())
        seen.update({r["id"]: r for r in new})
        page += 1

tiles = all_tiles()
movies = [t for t in tiles if t["type"] == 1]
containers = [t for t in tiles if t["type"] in (2, 3, 4, 6)]   # read with getCollection
CLASSICGET /ZidooPoster/getVideoListOfSource
  • The registered files of one source. Rows don't say which title a file belongs to.
Example

Call

rows = call("/ZidooPoster/getVideoListOfSource", id=2)     # a source id from getSources

Response

{
  "status": 200,
  "data": [
    { "id": 4211, "type": 0, "name": "The Matrix (1999).mkv",
      "videoinfo": { "id": 1475, "uri": "/Movies/The Matrix (1999).mkv" } }
  ]
}

What it does

The files one source has registered (shape abbreviated; it varies by build). Rows don't say which title a file belongs to, so to find that, look the paths up with getIdsByPathname.

Using the result

registered = {r["videoinfo"]["uri"] for r in rows.get("data", []) if r.get("videoinfo")}
CLASSICGET /Poster/v2/getAggregations?type=11
  • The files in the library's "Unmatched" group (type-0 entries).
Example

Call

unmatched = call("/Poster/v2/getAggregations", type=11)

Response

{
  "start": 0, "count": -1, "total": 0,
  "array": [
    { "id": 5120, "type": 0, "name": "home video 2019-07-04.mp4",
      "videoinfo": { "id": 2210, "uri": "/Home Videos/home video 2019-07-04.mp4" } }
  ]
}

What it does

Files the library holds but couldn't match to a title. Each has an entry ID (id), so it can be matched with setPathnameMatch, changeMatchDirect or a rematch. total isn't reliable; count the array.

Using the result

for e in unmatched.get("array", []):
    print(e["id"], e["name"])
CLASSICGET /ZidooPoster/search?q=<text>&type=0&page=&pagesize=
  • Returns: {status, key, all:[{keyName, aggregation}], allSize, movieSize, tvSize, collectionSize}. Each hit's aggregation is a list row.
Example

Call

res = call("/ZidooPoster/search", q="matrix", type=0, page=1, pagesize=20)

Response

{
  "status": 200, "key": "matrix",
  "all": [
    { "keyName": { "filterName": "matrix", "keyNumber": 1 },
      "aggregation": { "id": 4210, "type": 1, "name": "The Matrix",
                       "year": "1999", "parentId": 2985, "aggregationId": 851 } }
  ],
  "allSize": 1, "movieSize": 1, "tvSize": 0, "collectionSize": 0
}

What it does

Searches the library by title. Each hit's aggregation is a normal list row, so its id is the entry ID other calls take.

Using the result

hits = [h["aggregation"] for h in res.get("all", [])]
for h in hits:
    print(h["id"], h["name"], h["year"])

Title detail

CLASSICGET /Poster/v2/getDetail?id=<entry>  ·  /ZidooPoster/getDetail?id=  ·  /Poster/getDetail?id=
  • Serves: movies and seasons only. A series and a collection answer 803; an episode, a file entry and an unmatched entry answer 803 or 804.
  • Returns:
    • The title, an aggregation{…} metadata block, and aggregations[]: one row per file for a movie, one per episode for a season.
    • Each row nests its file's details (uri, playPoint, lastWatchTime, addedTime). position appears on the root and on each row.
    • TV detail adds a tv{id, name, tmdbId, posterPath, backdropPath} block; tv.name is the series name.
    • In the metadata block, genres, directors and actors are space-separated ID strings.
  • Season detail lists the episodes, and each episode lists its files. The resume point and last-watched date of an episode are on those file rows (aggregations[].aggregations[].aggregation.lastWatchTime), not on the episode row.
  • Season detail also reports where you are in the show: currentWatchSeasonNumber, currentWatchEpisodeNumber and progress.
  • Asking for a single-season show's series ID returns its season. Right after a match change, episodes may be listed before their files are attached; read again after a second or two.
  • Row order isn't stable, even between two calls of the same route. Match rows by file URI.
  • Layout varies by HT build: some nest the metadata a level deeper (aggregation.aggregation), some use a wrapper key (data, result, detail, …). Parse defensively.
  • Episode IDs come from one provider only. A TVDb-matched show exposes episodeId (TVDb) and no tmdbId. A TMDB-matched show exposes tmdbId with episodeId:-1.
Example

Call

movie  = call("/Poster/v2/getDetail", id=4210)      # a movie
season = call("/Poster/v2/getDetail", id=1830)      # a season

Response

// movie (trimmed). Note: everything sits under a top-level "aggregation"
{
  "aggregation": {
    "id": 4210, "type": 1, "name": "The Matrix", "year": 1999,
    "watched": false, "position": 0, "duration": 8160000,
    "aggregation": { "tmdbId": 603, "imdbId": "tt0133093", "title": "The Matrix",
                     "posterPath": "/f89U3ADr1oiB1s9GkdPOEpXUk5H.jpg",
                     "certification": "R", "runtime": 136, "genres": "1 4" },
    "aggregations": [
      { "id": 4211, "type": 0, "name": "The Matrix (1999).mkv",
        "watched": false, "position": 0, "duration": 8160000,
        "aggregation": { "id": 1475, "uri": "/Movies/The Matrix (1999).mkv",
                         "playPoint": 0, "lastWatchTime": -1,
                         "addedTime": 1790288635484 } }
    ]
  },
  "directors": [ { "id": 3658, "name": "Lana Wachowski", "tmdbId": 9340 } ],
  "actors":    [ { "id": 49235, "name": "Keanu Reeves", "tmdbId": 6384 } ],
  "genres":    [ { "id": 1, "name": "Action", "tmdbId": 28 } ]
}

// season (trimmed): episodes -> their files -> lastWatchTime
{
  "aggregation": {
    "id": 1830, "type": 4, "name": "Breaking Bad Season 1",
    "aggregation": { "seasonNumber": 1, "tmdbId": 3572, "tvName": "Breaking Bad" },
    "aggregations": [
      { "id": 1831, "type": 5, "name": "Pilot", "position": 1814000,
        "aggregation": { "seasonNumber": 1, "episodeNumber": 1,
                         "tmdbId": 62085, "showId": 1396 },
        "aggregations": [
          { "id": 1832, "type": 0, "name": "Breaking Bad S01E01.mkv",
            "aggregation": { "uri": "/TV/Breaking Bad/Season 1/Breaking Bad S01E01.mkv",
                             "playPoint": 1814000, "lastWatchTime": 1780881534876 } }
        ] }
    ]
  },
  "tv": { "id": 10, "name": "Breaking Bad", "tmdbId": 1396 },
  "currentWatchSeasonNumber": 1, "currentWatchEpisodeNumber": 1
}

What it does

The full record of a movie or a season. For a movie, aggregation.aggregations lists its files. For a season, it lists the episodes, and each episode's own aggregations lists its files. Resume points, last-watched dates and file paths (uri, share-relative) live on the file rows. Match rows by uri, never by position: the order isn't stable.

Using the result

from datetime import datetime, timezone

def files_of(detail):
    """Every file row under a movie or season detail, with its episode (if any)."""
    root = detail.get("aggregation", detail)
    for row in root.get("aggregations", []):
        if row["type"] == 0:                       # a movie's file
            yield None, row
        for f in row.get("aggregations", []):      # a season's episode -> its files
            yield row, f

for episode, f in files_of(season):
    meta = f["aggregation"]
    when = meta.get("lastWatchTime", -1)
    print(episode["name"] if episode else "", meta["uri"], meta.get("playPoint"),
          datetime.fromtimestamp(when / 1000, timezone.utc) if when > 0 else "never")
CLASSICGET /ZidooPoster/edit/config?id=<entry>

The edit record. It's the only read that answers for every entry type except seasons.

  • Returns:
    • id, type, name, parentId (scraped collection) and collectionId (user collection);
    • favor, watched, lock, and the quality tags isBluRay, is3d, is4k, isFHD, isDvd, is2k, isHD;
    • genres[], and directors[]/actors[] with local person IDs;
    • aggregation{…};
    • videos[]: one row per file, {id, path, position, duration, watched, favor}, with no lastWatchTime;
    • config{genres, certifications}: the player's full genre and rating lists.
  • Episodes keep their favorite and watched state on their file(s), not on the episode entry. A title counts as watched only when every one of its files is.
  • A series answers with metadata but no config, genres or videos. A season has no edit record (804).
  • It can lag changes made on the TV by a minute or more.
Example

Call

rec = call("/ZidooPoster/edit/config", id=4210)

Response

{
  "id": 4210, "parentId": 2985, "collectionId": -1, "type": 1, "name": "The Matrix",
  "favor": false, "watched": false, "lock": false,
  "isBluRay": false, "is3d": false, "is4k": true, "isHdr": true,
  "aggregation": { "title": "The Matrix", "overview": "...", "releaseDate": "1999-03-30",
                   "certification": "R", "runtime": 136, "tmdbId": 603 },
  "genres":    [ { "id": 1, "name": "Action", "tmdbId": 28 } ],
  "directors": [ { "id": 3658, "name": "Lana Wachowski", "tmdbId": 9340, "type": 0 } ],
  "actors":    [ { "id": 49235, "name": "Keanu Reeves", "tmdbId": 6384, "type": 1 } ],
  "videos": [
    { "id": 4211, "type": 0, "name": "The Matrix (1999).mkv",
      "path": "smb://192.168.1.10/Media/Movies/The Matrix (1999).mkv",
      "position": 0, "duration": 8160000, "watched": false, "favor": false }
  ],
  "config": {
    "genres": [ { "id": 1, "name": "Action", "tmdbId": 28 } ],
    "certifications": [ { "certification": "PG-13", "order": 3 },
                        { "certification": "R", "order": 4 } ]
  }
}

What it does

The edit record: flags, metadata, people and every file (videos[]) with its file ID, source path, resume point and watched flag. It's the read to use before update/detail, and the source of the file IDs for setPlayPoint and markAsWatched. An episode's favorite and watched state live on its file rows.

Using the result

files = [{"file_id": v["id"], "path": v["path"],
          "resume_ms": max(v["position"], 0), "length_ms": v["duration"],
          "watched": v["watched"]} for v in rec.get("videos", [])]
title_watched = bool(files) and all(f["watched"] for f in files)
rating_order = {c["certification"]: c["order"] for c in rec["config"]["certifications"]}
CLASSICGET /Poster/v2/getAggregation?id=<entry>&appends=aggregation
  • Returns: aggregation{dataType, tmdbId, tvdbId, imdb, seasonNumber, tvName, name}.
  • Collections: a scraped collection's aggregation.tmdbId is TMDB's collection ID, the one value that identifies it across players. User collections, and collections built from NFO files without an ID, return 0.
  • Series:
    • dataType 13 is a show. It carries tvdbId and imdb if matched on TVDb, or tmdbId if matched on TMDB (rarely both).
    • dataType 1 is a season listed on its own; there tmdbId is TMDB's season ID.
Example

Call

coll = call("/Poster/v2/getAggregation", id=2985, appends="aggregation")   # a collection

Response

{
  "id": 2985, "type": 2, "name": "The Matrix Collection",
  "aggregation": { "dataType": 2, "tmdbId": 2344, "name": "The Matrix Collection" }
}

What it does

The entry plus its metadata block. For a scraped collection, aggregation.tmdbId is TMDB's collection ID, the one value that identifies the collection on every player. For a series, dataType 13 is a show and 1 is a season listed on its own.

Using the result

tmdb_collection_id = (coll.get("aggregation") or {}).get("tmdbId", 0)
key = f"tmdb:{tmdb_collection_id}" if tmdb_collection_id > 0 else f"name:{coll['name'].lower()}"

Entries & path lookups

NEWGET /ZidooPoster/v2/getEntry?id=<entry>&start=<n>&count=<1–500>
  • Returns:
    • id, metadataId, parentId, collectionId, type, name, path, videoInfoId;
    • tmdbId, tvdbId, imdbId, tmdbEpisodeId, tvdbEpisodeId, tmdbSeasonId, tmdbCollectionId, seasonNumber, episodeNumber;
    • children[] (direct children only; a file child also carries playPoint, its resume point), total, hasMore.
    An unknown ID answers 404; id=0 answers 400. Fast (about 30 ms).
  • Works for every entry type and returns it as asked: a series isn't replaced by its season, and a season isn't flattened.
  • Episodes: tmdbId is the show's TMDB ID; the episode's own is tmdbEpisodeId.
  • Provider IDs are only those of the provider the title was matched with; the others are null.
getEntry reads the database, not what the player shows.
  • It answers for entries left behind when their last file moved to another entry; these report total:0.
  • After a library rebuild, old movie entries can report total:1 while their file child has no path.
  • Treat a movie or episode as present only if one of its type-0 children has a path, and a container only if it has children.
  • A collection's children include only members that belong to it through parentId (scraped members). Members added by the user aren't listed; use getCollection.
Example

Call

movie   = call("/ZidooPoster/v2/getEntry", id=4210, start=0, count=40)
episode = call("/ZidooPoster/v2/getEntry", id=1831, start=0, count=40)

Response

{
  "status": 200, "code": "OK", "msg": "success",
  "id": 1831, "metadataId": 187, "parentId": 1830, "collectionId": -1,
  "type": 5, "name": "Pilot",
  "tmdbId": 1396, "tmdbEpisodeId": 62085, "tvdbId": null, "imdbId": null,
  "seasonNumber": 1, "episodeNumber": 1,
  "path": null, "videoInfoId": null,
  "children": [
    { "id": 1832, "parentId": 1831, "type": 0, "name": "Breaking Bad S01E01.mkv",
      "path": "smb://192.168.1.10/Media/TV/Breaking Bad/Season 1/Breaking Bad S01E01.mkv",
      "videoInfoId": 708, "playPoint": 1814000, "state": 2 }
  ],
  "start": 0, "total": 1, "hasMore": false
}

What it does

The fastest way to identify an entry: type, provider IDs, season/episode, and its direct children, each file with its source path and resume point (playPoint). For an episode, tmdbId is the show's ID; the episode's is tmdbEpisodeId. Use it to confirm an ID still names the title you expect before writing to it.

Using the result

def still_is(entry_id, tmdb_id):
    e = call("/ZidooPoster/v2/getEntry", id=entry_id, start=0, count=40)
    if e.get("code") != "OK":
        return False
    live = any(c["type"] == 0 and c.get("path") for c in e["children"]) or e["type"] in (2, 3, 4, 6)
    key = e.get("tmdbEpisodeId") if e["type"] == 5 else e.get("tmdbId")
    return live and key == tmdb_id

ok_to_write = still_is(4210, 603)
NEWGET /ZidooPoster/v2/getIdByPathname?path=<source URL>
  • Returns: {status, code, id, type, fileId, videoInfoId, matched}. id is the movie or episode holding the file; fileId is the file entry (the ID play-state calls take).
  • For a registered but unmatched file, matched is false and id is the file entry itself.
  • Requires the source URL spelling (see File paths).
Example

Call

hit = call("/ZidooPoster/v2/getIdByPathname",
           path="smb://192.168.1.10/Media/Movies/The Matrix (1999).mkv")

Response

{ "status": 200, "code": "OK", "id": 4210, "type": 1,
  "fileId": 4211, "videoInfoId": 1475, "matched": true }

// a path the library doesn't hold
{ "status": 404, "code": "NOT_FOUND", "msg": "No registered file at this path" }

What it does

Finds the title holding one file. id is the movie or episode; fileId is the file entry (the ID setPlayPoint takes). For an unmatched file, matched is false and id is the file itself.

Using the result

if hit.get("code") == "OK":
    title_id, file_id = hit["id"], hit["fileId"]
NEWGET /ZidooPoster/v2/getIdsByPathname?paths=<URL-encoded JSON array of paths>
  • Returns: {status, results:{"<path>":{status, code, id, type, fileId, videoInfoId, matched}}, map:{"<path>":id}}. A path with no registered file has status 404 inside results.
  • Batch size: up to 500 paths are accepted, but the paths travel in the URL, and long URLs are refused. Send about 40 per request. Running 4 requests at once resolves a 2,000-file library in a few seconds; more than 4 at once isn't faster.
  • The best way to identify titles reliably: a file's path doesn't change when the library is rebuilt, and it is the same on every player that shares the storage.
Example

Call

import json
paths = ["smb://192.168.1.10/Media/Movies/The Matrix (1999).mkv",
         "smb://192.168.1.10/Media/Movies/Heat (1995).mkv"]
res = call("/ZidooPoster/v2/getIdsByPathname", paths=json.dumps(paths))

Response

{
  "status": 200, "code": "OK",
  "map": {
    "smb://192.168.1.10/Media/Movies/The Matrix (1999).mkv": 4210,
    "smb://192.168.1.10/Media/Movies/Heat (1995).mkv": null
  },
  "results": {
    "smb://192.168.1.10/Media/Movies/The Matrix (1999).mkv":
      { "status": 200, "code": "OK", "id": 4210, "type": 1,
        "fileId": 4211, "videoInfoId": 1475, "matched": true },
    "smb://192.168.1.10/Media/Movies/Heat (1995).mkv":
      { "status": 404, "code": "NOT_FOUND", "msg": "No registered file at this path" }
  }
}

What it does

The same lookup for many files at once. Send about 40 paths per request, up to 4 requests at a time. Read results: a path that isn't registered has its own 404 inside. This is the most reliable way to pair a title across players or to re-find a title after its ID changed.

Using the result

import json
from concurrent.futures import ThreadPoolExecutor

def lookup(all_paths, batch=40):
    batches = [all_paths[i:i + batch] for i in range(0, len(all_paths), batch)]
    def one(b):
        return call("/ZidooPoster/v2/getIdsByPathname", paths=json.dumps(b)).get("results", {})
    out = {}
    with ThreadPoolExecutor(max_workers=4) as pool:
        for part in pool.map(one, batches):
            out.update({p: r for p, r in part.items() if r.get("code") == "OK"})
    return out           # path -> {"id", "fileId", "type", "matched"}

Editing metadata

CLASSICGET /ZidooPoster/update/detail?id=<entry>&detail=<URL-encoded JSON>
Replaces, doesn't merge. Every field the call supports is replaced, and an omitted field may be written empty or zero: detail={} blanks the title, overview, rating and genres. Read the edit record first and send the full set of fields.
  • Fields:
    • title, releaseDate, overview, productionCountries;
    • runTime (capital T when writing);
    • certification{name, order}, with order taken from the edit record's config.certifications;
    • genres[] as an array of names (objects are stored as their JSON text);
    • directors[]/actors[] as {id, name, profilePath, tag, query:null};
    • videos[{id, aggregationId, types}], one per file. types is a bitmask: Blu-ray 1, 3D 2, 4K 4, FHD 8, DVD 16, 2K 32, HD 64. HDR comes from the video stream and can't be set.
  • Person tag says which ID you're sending: 0 = a local person ID from the edit record; non-zero = a TMDb person ID, which the player looks up or creates. Sending a TMDb ID with tag 0 attaches a different, unrelated person. For a person with no picture, send profilePath as the string "null". Always send both arrays.
  • runTime is accepted but, on current builds, the value isn't stored.
  • These are never changed by this call: tagline, homepage, status, budget, revenue, original title and language, spoken languages, production companies, ratings.
  • Series and seasons can't be edited: a series answers 804, and seasons have no edit route.
  • Works with the HT app closed. Payloads of several KB are fine.
Genres can't be removed. Every genre name you send is added to the player's genre list permanently. No endpoint deletes, renames or merges genres, so validate names before sending. (A genre can be deleted on the TV: in the filter dialog, focus it, press MENU, then Delete.)
Some players have duplicate genres with the same name. A title stored under the duplicate doesn't appear in that genre's filter.
Example

Call

import json
rec = call("/ZidooPoster/edit/config", id=4210)          # 1. read the full record
detail = {                                                # 2. send it ALL back, changed
    "title": "The Matrix",
    "overview": rec["aggregation"]["overview"],
    "releaseDate": rec["aggregation"]["releaseDate"],
    "productionCountries": rec["aggregation"].get("productionCountries", ""),
    "runTime": rec["aggregation"].get("runtime", 0),
    "certification": {"name": "R", "order": 4},
    "genres": ["Action", "Science Fiction"],
    "directors": [{"id": d["id"], "name": d["name"], "profilePath": d.get("profilePath") or "null",
                   "tag": 0, "query": None} for d in rec["directors"]],
    "actors":    [{"id": a["id"], "name": a["name"], "profilePath": a.get("profilePath") or "null",
                   "tag": 0, "query": None} for a in rec["actors"]],
    "videos": [{"id": v["id"], "aggregationId": v["aggregationId"], "types": 4} for v in rec["videos"]],
}
res = call("/ZidooPoster/update/detail", id=4210, detail=json.dumps(detail))

Response

{ "status": 200, "msg": "success" }

What it does

Replaces the title's editable metadata. Always start from the edit record and send every field, or the missing ones are blanked. People from the edit record use tag 0 (local IDs); people from search/person use a non-zero tag (TMDb IDs). types is the quality bitmask (4 = 4K). Confirm by reading the edit record again.

Using the result

after = call("/ZidooPoster/edit/config", id=4210)
assert after["aggregation"]["certification"] == "R"
CLASSICGET /ZidooPoster/search/person?name=<text>
  • TMDb person search: results[{id, name, profile_path, known_for_department}]. These are TMDb IDs; send them with a non-zero tag.
Example

Call

people = call("/ZidooPoster/search/person", name="Keanu Reeves")

Response

{
  "page": 1,
  "results": [
    { "id": 6384, "name": "Keanu Reeves", "known_for_department": "Acting",
      "profile_path": "/4D0PpNI0kmP58hgrwGC3wCjxhnm.jpg",
      "known_for": [ { "id": 603, "title": "The Matrix", "media_type": "movie" } ] }
  ]
}

What it does

A TMDb person search run by the player. The IDs are TMDb IDs, so when adding a person to a title with update/detail, send them with a non-zero tag.

Using the result

p = people["results"][0]
new_actor = {"id": p["id"], "name": p["name"],
             "profilePath": p["profile_path"] or "null", "tag": 1, "query": None}

Watched, favorite, lock, resume point

CLASSICGET /Poster/v2/favorite?id=<entry>&favor=true|false
  • Changes only the favorite flag. Works with the HT app closed.
  • For an episode, the player stores the favorite on its file.
  • The change can take a moment to show; read back for up to ~3 s.
Example

Call

call("/Poster/v2/favorite", id=4210, favor="true")

Response

{ "status": 200, "msg": "success" }

What it does

Sets or clears the favorite flag. Read it back from the edit record; it can take a moment to show.

Using the result

import time
for _ in range(6):
    time.sleep(0.5)
    if call("/ZidooPoster/edit/config", id=4210).get("favor"):
        break
CLASSICGET /ZidooPoster/lock?id=<entry>&lock=true|false
  • Child lock. Works with the HT app closed.
Example

Call

call("/ZidooPoster/lock", id=4210, lock="true")

Response

{ "status": 200, "msg": "success" }

What it does

Sets or clears the child lock. Confirm with the edit record's lock.

Using the result

locked = call("/ZidooPoster/edit/config", id=4210)["lock"]
CLASSICGET /Poster/v2/markAsWatched?aggregationId=<file ID>&watched=1
  • Takes a file ID. Sending the title's ID answers success and changes nothing. Call it once per file.
  • Answers 200 even for an ID that doesn't exist.
  • Sets the last-watched time to the moment of the call, and parks any resume point as a negative value. To set watched with a specific date, use setPlayPoint instead.
Don't send watched=0. On some players (Z9X Pro) it closes the HT app. To mark something unwatched, use remove/history.
Example

Call

rec = call("/ZidooPoster/edit/config", id=4210)
for v in rec["videos"]:                       # once per FILE
    call("/Poster/v2/markAsWatched", aggregationId=v["id"], watched=1)

Response

{ "status": 200, "msg": "success" }

What it does

Marks files watched, one file ID at a time. It records "now" as the last-watched time; to set a different date, use setPlayPoint. Never send watched=0; use remove/history to unwatch.

Using the result

watched_now = all(v["watched"] for v in call("/ZidooPoster/edit/config", id=4210)["videos"])
CLASSICGET /ZidooPoster/remove/history?id=<entry>
  • Marks the title unwatched. This is how the player's own web page does it.
Example

Call

call("/ZidooPoster/remove/history", id=4210)

Response

{ "status": 200, "msg": "success" }

What it does

Marks the title unwatched, the same way the player's own web page does.

Using the result

assert not call("/ZidooPoster/edit/config", id=4210)["watched"]
NEWGET /ZidooPoster/v2/setPlayPoint?id=<file ID>&playPoint=<ms>[&lastWatchTime=<unix ms>][&duration=<ms>]
  • ID: the file ID: videos[].id from the edit record, or fileId from a path lookup. Read it fresh before writing.
  • Replies: 804 = not available on this HT build. 404 ("Video not found!" / "Video aggregation not found!") = wrong ID. 400 = negative playPoint.
  • The change is visible immediately.
  • lastWatchTime: if omitted, set to now; if given, stored as sent. It is the only way to set a watched date.
  • The player marks the file watched itself once the point is within 5 minutes of the end, or at 93% or more, whichever comes first. So short files count from 5 minutes before the end, and long films from 93% (about 8 minutes before the end of a two-hour film). The same applies with or without duration.
  • The player's Bookmark Start/End settings don't affect API writes. A point under 1 minute is stored as given.
  • playPoint=0 clears the resume point, the last-watched time and the watched flag.
To mark a file watched with a chosen date: playPoint=<duration>&duration=<duration>&lastWatchTime=<date>. A resume point short of the end clears watched; see Watched & resume points.
Example

Call

from datetime import datetime, timezone
watched_on = int(datetime(2026, 9, 14, 20, 30, tzinfo=timezone.utc).timestamp() * 1000)

# a resume point at 45:00, last watched on Sep 14
call("/ZidooPoster/v2/setPlayPoint", id=4211, playPoint=2_700_000,
     lastWatchTime=watched_on, duration=8_160_000)

# mark the file watched with that date: point at the end
call("/ZidooPoster/v2/setPlayPoint", id=4211, playPoint=8_160_000,
     lastWatchTime=watched_on, duration=8_160_000)

Response

{ "status": 200, "msg": "success" }

// wrong ID (a title ID instead of the file ID)
{ "status": 404, "msg": "Video aggregation not found!" }

// HT build without this call
{ "status": 804, "msg": "Error!!!" }

What it does

Sets a file's resume point and, optionally, its last-watched date. The ID is the file ID (videos[].id). A point within 5 minutes of the end, or past 93%, also marks the file watched; an earlier point clears watched. playPoint=0 clears everything. The new value is visible immediately.

Using the result

def resume_point(entry_id, file_id):
    for v in call("/ZidooPoster/edit/config", id=entry_id)["videos"]:
        if v["id"] == file_id:
            return max(v["position"], 0), v["watched"]

print(resume_point(4210, 4211))      # (8160000, True)

Matching

NEWGET /ZidooPoster/v2/setPathnameMatch?path=<source URL>&tmdbId=<id>[&season=<s>&episode=<e>]
  • Matches one file directly to a TMDB title or episode: no search step, and other files of the same title are unaffected.
  • Returns: {status, code, id, type, fileId, matched, filesUpdated}. The change applies at once and the file's old entry goes away.
  • 409 LIBRARY_BUSY while the library is scanning. Wait for the scan (see getSources scanStatus) or retry a few seconds apart.
  • Switches the provider for that file (e.g. TVDb → TMDB). Categories filed on the old season don't follow it.
  • A path the player can't reach answers 404 FILE_UNAVAILABLE ("File is not accessible"). Whether it can match a file a scan hasn't registered yet is not yet verified.
  • Not documented: &tvdbId=<series TVDb ID> in place of tmdbId matches to a TVDb series. If the player can't reach TVDb, the file can land on a series the player doesn't list, leaving the episode unplayable. Confirm TVDb works with a search first.
The reply isn't reliable: matches that succeeded have answered 500 OPERATION_FAILED. Always confirm with getIdByPathname and getEntry.
Example

Call

res = call("/ZidooPoster/v2/setPathnameMatch",
           path="smb://192.168.1.10/Media/TV/Breaking Bad/Season 1/Breaking Bad S01E01.mkv",
           tmdbId=1396, season=1, episode=1)

Response

{ "status": 200, "code": "OK", "id": 1831, "type": 5,
  "fileId": 1832, "matched": true, "filesUpdated": 1 }

// while the library is scanning
{ "status": 409, "code": "LIBRARY_BUSY" }

// a path the player can't reach
{ "status": 404, "code": "FILE_UNAVAILABLE", "msg": "File is not accessible" }

What it does

Matches one file straight to a TMDB movie (tmdbId only) or episode (show tmdbId plus season and episode). Retry after a pause on 409. Always confirm the result with a path lookup: the reply has reported failure for matches that worked.

Using the result

import time
path = "smb://192.168.1.10/Media/TV/Breaking Bad/Season 1/Breaking Bad S01E01.mkv"
for attempt in range(5):
    r = call("/ZidooPoster/v2/setPathnameMatch", path=path, tmdbId=1396, season=1, episode=1)
    if r.get("code") != "LIBRARY_BUSY":
        break
    time.sleep(3)
hit = call("/ZidooPoster/v2/getIdByPathname", path=path)
e = call("/ZidooPoster/v2/getEntry", id=hit["id"], start=0, count=1)
assert (e["seasonNumber"], e["episodeNumber"]) == (1, 1)
NEWGET /ZidooPoster/v2/changeMatchDirect?id=<entry or file ID>&tmdbId=<id>[&season=&episode=]
  • The same direct match, addressed by ID instead of path. Prefer the file ID.
Example

Call

res = call("/ZidooPoster/v2/changeMatchDirect", id=4211, tmdbId=603)

Response

{ "status": 200, "code": "OK", "id": 4210, "type": 1,
  "fileId": 4211, "matched": true, "filesUpdated": 1 }

What it does

The same direct match, addressed by file (or entry) ID. Use it when you can't build the player's path for the file. Confirm the result the same way.

Using the result

landed_on = res.get("id")
CLASSICRematch by search: v2/rematch/search → v2/rematch/match → v2/rematch/save

The flow the player's web page uses, and the only matching method before HT 5.1.03.

  1. GET /ZidooPoster/v2/rematch/search?query=<title>&api=tmdb|tvdb → {code, data:{api, dataId, data:[{title, image, date, id, isTv, voteAverage}]}}.
    • The player runs the search online, so this step is slow.
    • TMDB results carry no TMDB ID and no separate year, only date. The poster path in image is the most reliable way to tell results apart.
    • Movie and TV results are mixed together; filter on isTv.
  2. GET /ZidooPoster/v2/rematch/match?api=&id=<ID>&index=<i>&target=<JSON>&dataId= → data.adjusts[{operate, seasonNumber, episodeNumber, name, isTV}]. TV replies omit type; take it from the result's isTv.
  3. GET /ZidooPoster/v2/rematch/save?id=<ID>&api=&dataId=&index=&target=<JSON>&type=0|1&adjusts=<JSON> → data{id, name, year}, the entry the file now belongs to.
  • Per-file rematch: pass a file ID (from videos[]) to rematch just that file. This is how to split files that were wrongly grouped under one title. An episode ID or season ID answers 804.
  • Run match and save as a pair for each file, with the same ID: the save applies to the ID used in the match call.
  • target: {title, image, date, id (TVDb results only), isTv, voteAverage, index}. Leave out image when it ends in null: the player tries to download it and the save fails.
  • adjusts: send back every field you received; dropping one fails with 804. For TV, send operate=1 (match returns 0). Season 0 (specials) is valid.
  • Provider: rematching single episodes keeps the series on its current provider. To change a show's provider, rematch every episode of the show in one pass, or use setPathnameMatch.
  • Saves are refused when sent in quick succession and when the HT app isn't running. Retry with a growing pause.
  • A rematch re-downloads the clearlogo from TMDB, replacing a logo file you placed next to the video.
  • Older single-step routes: /ZidooPoster/rematch/search?query= and /ZidooPoster/rematch/save?id=&target=&type=0|1|-1&adjusts=.
Example

Call

import json
# 1. search (the player queries TMDB online - slow)
found = call("/ZidooPoster/v2/rematch/search", query="The Matrix", api="tmdb")
pick = next(r for r in found["data"]["data"]
            if r["title"] == "The Matrix" and r["date"].startswith("1999") and not r["isTv"])
index = found["data"]["data"].index(pick)
target = {k: pick[k] for k in ("title", "image", "date", "isTv", "voteAverage")}
target["index"] = index

# 2. match: the ID is the FILE ID
m = call("/ZidooPoster/v2/rematch/match", api="tmdb", id=4211, index=index,
         target=json.dumps(target), dataId=found["data"]["dataId"])

# 3. save, echoing adjusts back unchanged (TV: set operate=1)
s = call("/ZidooPoster/v2/rematch/save", id=4211, api="tmdb", dataId=found["data"]["dataId"],
         index=index, target=json.dumps(target), type=0,
         adjusts=json.dumps(m["data"]["adjusts"]))

Response

// search
{ "code": 200,
  "data": { "api": "tmdb", "dataId": "1791088184183",
            "data": [ { "title": "The Matrix", "date": "1999-03-30", "isTv": 0,
                        "voteAverage": 8.2,
                        "image": "http://image.tmdb.org/t/p/w185/f89U3ADr1oiB1s9GkdPOEpXUk5H.jpg" } ] } }

// match
{ "status": 200,
  "data": { "adjusts": [ { "operate": 0, "seasonNumber": 0, "episodeNumber": 0,
                           "name": "The Matrix (1999).mkv", "isTV": false } ] } }

// save: the entry the file now belongs to
{ "status": 200, "msg": "success", "data": { "id": 4210, "name": "The Matrix", "year": 1999 } }

What it does

The player web page's three-step rematch. Choose the result by title and date: TMDB results carry no ID. Run match and save as a pair, with the same file ID, and send adjusts back as received (for TV, with operate set to 1). Drop image from the target if it ends in null.

Using the result

new_entry = s["data"]["id"] if s.get("status") == 200 else None
CLASSICGET /ZidooPoster/clear?id=<entry>
  • Removes the match and moves the file to "Unmatched". The file stays in the library under its ID, so it can be rematched.
  • A rescan does not match it again, even with an NFO present; rematch it explicitly.
Example

Call

call("/ZidooPoster/clear", id=4210)

Response

{ "status": 200, "msg": "success" }

What it does

Unmatches the title: its file moves to "Unmatched" and keeps its ID. It stays unmatched until you match it again (a rescan won't).

Using the result

still_there = call("/ZidooPoster/v2/getIdByPathname",
                   path="smb://192.168.1.10/Media/Movies/The Matrix (1999).mkv")
print(still_there.get("matched"))     # False
CLASSICGET /ZidooPoster/remove/aggregation?id=<entry>
Use with great care. It removes the file from the library, and on HT5 the file may never be added back by a scan.
  • The player can list the file name as blocked under HT Settings → Data Manager. Deleting it there, or renaming the file and rescanning, brings it back.
  • No endpoint can add a file back.
  • Passing a series, season or collection ID removes everything under it.
Example

Call

call("/ZidooPoster/remove/aggregation", id=4211)     # a FILE entry only

Response

{ "status": 200, "msg": "success" }

What it does

Removes a file from the library. Use only when you mean it: on HT5 the file may never come back by itself (see the warning above). Prefer clear to unmatch.

Using the result

gone = call("/ZidooPoster/v2/getIdByPathname",
            path="smb://192.168.1.10/Media/Movies/The Matrix (1999).mkv").get("status") == 404

Collections

How collections work:
  • A title can be in at most one scraped collection (type 2, via parentId) and one user collection (type 6, via collectionId). When it's in both, the user collection is the one shown.
  • Adding a title to a collection removes it from its previous collection of that kind.
  • A scraped collection left with one member dissolves. A user collection persists even when empty.
  • When reading membership, check both fields.
CLASSICGET /ZidooPoster/getCollectionList
  • A JSON array of all collections (types 2 and 6, including empty user collections) and all series (type 3). The hidden "Unmatched" group isn't listed.
  • When busy it answers {"status":804} instead of an array. Never treat that as "no collections": creating them again makes duplicates. Retry after 0.6, 1.5 and 3 s.
Example

Call

import time
def collection_list():
    for wait in (0, 0.6, 1.5, 3):
        time.sleep(wait)
        body = call("/ZidooPoster/getCollectionList")
        if isinstance(body, list):          # success is a bare array
            return body
    raise RuntimeError("player busy - collection list unavailable")
cols = collection_list()

Response

[
  { "id": 2985, "parentId": -1, "collectionId": -1, "aggregationId": 19,
    "type": 2, "name": "The Matrix Collection" },
  { "id": 3301, "parentId": -1, "collectionId": -1, "aggregationId": 31,
    "type": 6, "name": "Movie Night" },
  { "id": 1829, "parentId": -1, "collectionId": -1, "aggregationId": 14,
    "type": 3, "name": "Breaking Bad" }
]

// when busy: an object instead of the array
{ "status": 804, "msg": "Error!!!" }

What it does

Every collection, plus every series. Type 2 is scraped, type 6 user-made, type 3 a series. A busy player answers with an object instead of an array; never treat that as "no collections".

Using the result

scraped = [c for c in cols if c["type"] == 2]
user    = [c for c in cols if c["type"] == 6]
series  = [c for c in cols if c["type"] == 3]
by_name = {c["name"].casefold(): c["id"] for c in scraped + user}
CLASSICGET /ZidooPoster/getCollection?id=<ID>
  • Members of a collection (scraped and user-added), the seasons of a series, or the episodes of a season.
  • Also lists the file entries (type 0) of member movies; filter by type.
  • "Unmatched" members carry their file in videoinfo{id, uri}. That videoinfo.id is not an entry ID, so don't send it to other calls.
  • Occasionally answers 804 on an idle player; retry before treating it as empty.
Example

Call

members = call("/ZidooPoster/getCollection", id=2985)

Response

{
  "status": 200,
  "data": [
    { "id": 4210, "parentId": 2985, "collectionId": -1, "type": 1, "name": "The Matrix" },
    { "id": 4211, "parentId": 4210, "collectionId": -1, "type": 0, "name": "The Matrix (1999).mkv" },
    { "id": 4220, "parentId": 2985, "collectionId": -1, "type": 1, "name": "The Matrix Reloaded" }
  ]
}

What it does

The members of a collection (or a series' seasons, or a season's episodes). Member movies' file rows (type 0) are listed too; filter them out. If it answers 804, retry rather than assume it's empty.

Using the result

titles = [m for m in members.get("data", []) if m["type"] != 0]
CLASSICGET /ZidooPoster/addToNewCollection?name=<name>&id=<entry>
  • Creates a user collection containing one title. It appears in getCollectionList shortly after; poll briefly before using its ID.
Example

Call

call("/ZidooPoster/addToNewCollection", name="Movie Night", id=4210)

Response

{ "status": 200, "msg": "success" }

What it does

Creates a user collection holding one title. Read getCollectionList for its ID; it can take a moment to appear.

Using the result

import time
for _ in range(20):
    time.sleep(0.35)
    new = [c for c in call("/ZidooPoster/getCollectionList")
           if isinstance(c, dict) and c["name"] == "Movie Night"]
    if new:
        movie_night = new[0]["id"]; break
CLASSICGET /ZidooPoster/addToCollection?collectionId=<collection>&aggregationId=<entry ID>
  • aggregationId takes the title's entry ID.
  • If the collection ID no longer exists, the player creates a new collection instead of failing. Re-read getCollectionList before adding, and reuse an existing collection of the same name.
  • Works with the HT app closed.
Example

Call

call("/ZidooPoster/addToCollection", collectionId=3301, aggregationId=4220)   # entry ID

Response

{ "status": 200, "msg": "success" }

What it does

Adds a title (entry ID, despite the parameter name) to a collection, moving it out of any other collection of the same kind. Make sure the collection still exists first: an unknown ID creates a new collection.

Using the result

rec = call("/ZidooPoster/edit/config", id=4220)
assert rec["collectionId"] == 3301      # user collection; scraped ones show in parentId
CLASSICGET /ZidooPoster/removeFromCollection?id=<entry>
  • Takes only id; adding other parameters answers 804.
  • Answers success even when nothing changed. A member of a scraped collection can't be removed: the player re-derives it from the title's metadata, and changing the match or the NFO <set> is the way. It also does nothing while the HT app is closed. Read back.
  • Afterwards collectionId reads −2. A title removed from a user collection becomes standalone; it doesn't return to its scraped collection.
Example

Call

call("/ZidooPoster/removeFromCollection", id=4220)

Response

{ "status": 200, "msg": "success" }

What it does

Removes a title from its user collection. It answers success even when nothing changes, so read back.

Using the result

import time
time.sleep(0.3)
removed = call("/ZidooPoster/edit/config", id=4220)["collectionId"] in (-1, -2)
CLASSICGET /ZidooPoster/disbandCollection?id=<collection>
  • Deletes a collection; the titles remain. There is no rename endpoint: create a new collection and move the titles.
Fails (804) on an empty collection. Delete those from the TV's menu.
Example

Call

call("/ZidooPoster/disbandCollection", id=3301)

Response

{ "status": 200, "msg": "success" }

// an empty collection
{ "status": 804, "msg": "Error!!!" }

What it does

Deletes a collection; its titles stay in the library.

Using the result

exists = any(c.get("id") == 3301 for c in call("/ZidooPoster/getCollectionList") if isinstance(c, dict))

Categories

The API calls categories "albums". A title can be in any number of categories, and a category with one title persists. Category IDs are local to the player and can be reused after a library rebuild.

CLASSICGET /Poster/v2/getAlbums?start=0&count=1000
  • Returns: {start, count, total, array:[{id, name}]}.
Example

Call

albums = call("/Poster/v2/getAlbums", start=0, count=1000)

Response

{ "start": 0, "count": 1000, "total": 5,
  "array": [ { "id": 1, "name": "Favorites of 2024" }, { "id": 2, "name": "Westerns" } ] }

What it does

All categories on the player, by ID and name.

Using the result

category_id = {a["name"]: a["id"] for a in albums["array"]}
CLASSICGET /Poster/v2/getAlbumAggregations?albumId=<category>&start=0&count=2000
  • Returns: array[{id, aggregationId, type, name}]: the entries the category is filed on, exactly as the TV shows them.
  • Lists containers, not their contents: a category filed on a collection returns the collection alone. Members are movies, collections, seasons, and series.
  • Ignore total; it is wrong (often negative). Count the array.
Example

Call

filed = call("/Poster/v2/getAlbumAggregations", albumId=2, start=0, count=2000)

Response

{ "start": 0, "count": 2000, "total": -1995,
  "array": [
    { "id": 2985, "aggregationId": 19, "type": 2, "name": "The Matrix Collection" },
    { "id": 1830, "aggregationId": 18, "type": 4, "name": "Breaking Bad Season 1" },
    { "id": 4300, "aggregationId": 902, "type": 1, "name": "Unforgiven" }
  ] }

What it does

What a category is filed on, exactly as the TV shows it: movies, collections, seasons and series. Collections aren't expanded. Ignore total.

Using the result

rows = filed["array"]                # not filed["total"]
containers = [r for r in rows if r["type"] in (2, 3, 4, 6)]
NEWGET /ZidooPoster/v2/getAlbumMembers?albumId=<category>&start=<n>&count=500
  • Returns: {status, members:[{status, code, id, fileId, videoInfoId, type, matched}], hasMore}, at file level, with collections expanded into their titles.
  • One member per file, so a title with several files appears several times; group by id. Check each member's own status.
  • Collections are never listed themselves; use getAlbumAggregations to see what a category is filed on.
Example

Call

def album_titles(album_id):
    ids, start = set(), 0
    while True:
        page = call("/ZidooPoster/v2/getAlbumMembers", albumId=album_id, start=start, count=500)
        ids |= {m["id"] for m in page.get("members", []) if m.get("code", "OK") == "OK"}
        if not page.get("hasMore"):
            return ids
        start += 500
titles = album_titles(2)

Response

{
  "status": 200, "code": "OK",
  "members": [
    { "status": 200, "code": "OK", "id": 4210, "fileId": 4211, "videoInfoId": 1475,
      "type": 1, "matched": true },
    { "status": 200, "code": "OK", "id": 4220, "fileId": 4221, "videoInfoId": 1490,
      "type": 1, "matched": true }
  ],
  "hasMore": false
}

What it does

Every title in a category, with collections expanded, one row per file. Group by id to get titles, and check each member's own status.

Using the result

print(len(titles), "titles in the category")
CLASSICGET /Poster/v2/addToAlbum?albumId=<category>&aggregationId=<entry ID>  ·  /Poster/v2/removeFromAlbum?albumId=&aggregationId=<entry ID>
  • aggregationId takes the entry ID.
  • TV is filed by season. Adding episodes answers success and shows nothing. Once every season of a show is in a category, it lists the series. Removing one season from such a series then removes the whole show, so re-add the other seasons, and send removes before adds.
  • Filing a collection files its current titles; titles added to the collection later aren't included. Filing a single member of a collection files the whole collection.
  • Does nothing while the HT app is closed.
Some writes are silently dropped when sent close together, even 250 ms apart. Both calls are safe to repeat: read the category back and re-send whatever is missing. A single write shows within about 1.5 s.
Example

Call

import time
def add_to_category(album_id, entry_ids):
    for e in entry_ids:
        call("/Poster/v2/addToAlbum", albumId=album_id, aggregationId=e)
        time.sleep(0.3)
    time.sleep(1.5)
    filed = {r["id"] for r in call("/Poster/v2/getAlbumAggregations",
                                   albumId=album_id, start=0, count=2000)["array"]}
    for e in set(entry_ids) - filed:          # re-send what was dropped
        call("/Poster/v2/addToAlbum", albumId=album_id, aggregationId=e)

add_to_category(2, [4300, 1830])               # a movie and a SEASON

Response

{ "status": 200, "msg": "success" }

What it does

Adds or removes a title (entry ID). For TV, send season IDs. Some writes are dropped when sent close together, so read the category back and re-send what's missing.

Using the result

call("/Poster/v2/removeFromAlbum", albumId=2, aggregationId=4300)
CLASSICGET /Poster/v2/addToNewAlbum?aggregationId=<entry ID>&albumName=<name>
  • Creates a category containing one title. Re-read getAlbums for its ID.
Example

Call

call("/Poster/v2/addToNewAlbum", aggregationId=4300, albumName="Westerns")

Response

{ "status": 200, "msg": "success" }

What it does

Creates a category containing one title. Find its ID with getAlbums.

Using the result

new_id = next(a["id"] for a in call("/Poster/v2/getAlbums", start=0, count=1000)["array"]
              if a["name"] == "Westerns")
CLASSICGET /Poster/v2/deleteAlbum?albumId=<category>
  • Deletes the category; titles remain. The parameter must be albumId; id= answers success and does nothing.
Example

Call

call("/Poster/v2/deleteAlbum", albumId=2)

Response

{ "status": 200, "msg": "success" }

What it does

Deletes the category. The parameter must be albumId.

Using the result

gone = all(a["id"] != 2 for a in call("/Poster/v2/getAlbums", start=0, count=1000)["array"])
CLASSICGET /Poster/v2/getAlbumsWithAggregation?aggregationId=<entry ID>
Not reliable. It returns {albumIds:[…]}, but can list categories a title was removed from, list episode memberships the TV never shows, and omit real season memberships. Use getAlbumAggregations instead.
Example

Call

call("/Poster/v2/getAlbumsWithAggregation", aggregationId=4300)

Response

{ "albumIds": [ 1, 2 ] }

What it does

Which categories a title is in, but unreliable (see the warning). Build the same answer from getAlbumAggregations instead.

Using the result

in_categories = [a["id"] for a in call("/Poster/v2/getAlbums", start=0, count=1000)["array"]
                 if any(r["id"] == 4300 for r in call("/Poster/v2/getAlbumAggregations",
                        albumId=a["id"], start=0, count=2000)["array"])]

A category's icon can be set on the TV, but no endpoint reads or sets it.

Artwork

CLASSICGET /ZidooPoster/getImages?id=<entry>
  • TMDb choices for a title (images.posters, images.backdrops, images.logos), TMDB's image base URL (config), and the picture files found next to the video (localImages). Older builds return a different shape and no logos.
  • Answers 804 for a season (ask its series) and for titles matched on TVDb.
Example

Call

imgs = call("/ZidooPoster/getImages", id=4210)

Response

{
  "images": {
    "id": 603,
    "posters":   [ { "file_path": "/f89U3ADr1oiB1s9GkdPOEpXUk5H.jpg", "width": 2000, "height": 3000,
                     "iso_639_1": "en", "vote_average": 5.6, "vote_count": 12 } ],
    "backdrops": [ { "file_path": "/l4QHerTSbMI7qgvasqxP36pqjN6.jpg", "width": 1920, "height": 1080,
                     "iso_639_1": null, "vote_average": 5.4, "vote_count": 8 } ],
    "logos":     [ { "file_path": "/hwvwr9u7v7vKZWGtyXEh8ImCDXT.png", "width": 2000, "height": 659,
                     "iso_639_1": "en" } ]
  },
  "config": { "baseUrl": "http://image.tmdb.org/t/p/",
              "posterSizes": "[\"w92\",\"w185\",\"w342\",\"w500\",\"original\"]" },
  "localImages": {
    "poster":   "/mnt/smb/192.168.1.10#Media/Movies/The Matrix (1999)-poster.jpg",
    "backdrop": "/mnt/smb/192.168.1.10#Media/Movies/The Matrix (1999)-fanart.jpg"
  }
}

What it does

TMDB's poster, backdrop and logo choices for the title, plus any picture files found next to the video (localImages). Pass a file_path to update/image to apply it. Build a preview URL from config.baseUrl.

Using the result

best = max(imgs["images"]["posters"], key=lambda p: (p["iso_639_1"] == "en", p["vote_count"]))
preview = imgs["config"]["baseUrl"] + "w342" + best["file_path"]
call("/ZidooPoster/update/image/poster", id=4210, path=best["file_path"])
CLASSICGET /ZidooPoster/update/image/poster?id=<entry>&path=<TMDB file path>  ·  /ZidooPoster/update/image/backdrop?id=&path=
  • Sets artwork from a TMDB image path (e.g. /abc123.jpg). Only poster and backdrop exist; there's no logo, thumb or banner variant.
  • Applies within 1–2 seconds.
  • Works for movies, episodes and series. Season and collection backdrops are accepted but never displayed.
  • A TMDB path sent to a title matched on TVDb is accepted but not displayed.
Example

Call

call("/ZidooPoster/update/image/poster",   id=4210, path="/f89U3ADr1oiB1s9GkdPOEpXUk5H.jpg")
call("/ZidooPoster/update/image/backdrop", id=4210, path="/l4QHerTSbMI7qgvasqxP36pqjN6.jpg")

Response

{ "status": 200, "msg": "success" }

What it does

Applies a TMDB picture by its file_path. Confirm by downloading the poster and comparing it with the TMDB image, not by the reply.

Using the result

import requests
shown = requests.get(BASE + "/ZidooPoster/v2/getPoster", params={"id": 4210, "w": 500, "h": 750}).content
CLASSICPOST /ZidooPoster/update/image/poster/UploadFile?id=<entry>  ·  …/update/image/backdrop/UploadFile?id=
  • Uploads your own picture: multipart/form-data with one field named file (JPEG, PNG, GIF, WebP or BMP).
  • ID: the entry ID. Uploading to an aggregation ID answers 200 and changes nothing.
  • Applies at once, survives rescans, and works with the HT app closed. Multi-megabyte images are fine.
  • The posterPath/backdropPath the player reports doesn't change after an upload, so to confirm one, compare the image itself.
Leave at least 1 second between uploads. The player answers immediately, then stores the picture about half a second later from a buffer shared by all uploads. Uploads sent closer together can be stored on the wrong title: with 250–500 ms spacing, pictures shifted to the next title in the batch. A read straight after the upload can show the new picture even when it was misfiled, so wait a second or two, check the image, and check the title before it too.
Example

Call

import requests, time
with open("matrix-poster.jpg", "rb") as f:
    r = requests.post(BASE + "/ZidooPoster/update/image/poster/UploadFile",
                      params={"id": 4210},
                      files={"file": ("matrix-poster.jpg", f, "image/jpeg")}, timeout=60)
print(r.json())
time.sleep(1.5)                 # before the next upload, and before checking

Response

{ "status": 200, "msg": "success" }

What it does

Uploads your own picture for a title (entry ID). Leave at least a second between uploads, and confirm by comparing the downloaded image with what you sent, after a short wait.

Using the result

import hashlib
sent = hashlib.md5(open("matrix-poster.jpg", "rb").read()).hexdigest()
# the player re-encodes and crops to 2:3, so compare visually or with a perceptual hash,
# not byte-for-byte; a fast check is that the picture changed from the previous one
CLASSICGET /ZidooPoster/v2/getPoster?id=<entry>&w=<px>&h=<px>  ·  GET /Poster/v2/getBackdrop?id=<entry>
  • The image bytes. Some players answer on alternate routes: /ZidooPoster/v2/getBackdrop, /Poster/getBackdrop, /ZidooPoster/getBackdrop, /Poster/v2/getFile/getPoster.
  • Can answer HTTP 200 with a JSON {"status":803} body instead of an image; check the content. Backdrops sometimes come back as base64 inside JSON.
  • Posters are returned cropped to 2:3.
  • Gaps:
    • Episodes have no poster (803); the TV shows the episode still instead.
    • A season's backdrop returns the series backdrop.
    • A collection without its own art returns a member's art.
Example

Call

import requests
r = requests.get(BASE + "/ZidooPoster/v2/getPoster", params={"id": 4210, "w": 500, "h": 750}, timeout=30)

Response

HTTP 200, Content-Type: image/jpeg, <JPEG bytes>

// or, with HTTP 200 as well:
{ "status": 803, "msg": "The application element returns data that is empty or incorrect" }

What it does

Downloads the picture the player shows. Check the content before saving: an error can arrive as JSON with HTTP 200.

Using the result

def is_image(data: bytes) -> bool:
    return data[:3] == b"\xff\xd8\xff" or data[:8] == b"\x89PNG\r\n\x1a\n" or data[:4] == b"RIFF"

if is_image(r.content):
    open("poster-4210.jpg", "wb").write(r.content)

Sources

CLASSICGET /ZidooPoster/v2/getSources
  • Returns: an array of library sources, [{id, name, url, matchedCount, exist, connect, scanStatus}].
  • url includes the share's user name and password (smb://server/Share/folder?user=…&password=…). Remove them before storing or showing it.
  • scanStatus: 2 while scanning, 3 when finished.
  • Use the URLs to convert between share-relative paths and the source spelling the path endpoints need.
Example

Call

sources = call("/ZidooPoster/v2/getSources")

Response

[
  { "id": 2, "name": "Movies", "connect": true, "exist": true, "matchedCount": 1861,
    "scanStatus": 3, "scanMode": 1, "updateTime": 1791084561611,
    "url": "smb://192.168.1.10/Media/Movies?user=media&password=secret" }
]

What it does

The library's folders. Strip the credentials from url before using or logging it. scanStatus 2 means a scan is running, 3 that it's done. Use the URLs to build the smb:// paths the path calls need.

Using the result

from urllib.parse import urlsplit, urlunsplit

def clean(url):
    p = urlsplit(url)
    return urlunsplit((p.scheme, p.netloc, p.path, "", ""))   # drop ?user=&password=

roots = {s["name"]: clean(s["url"]) for s in sources}
scanning = any(s.get("scanStatus") == 2 for s in sources)

def player_path(share_relative, root=roots["Movies"]):
    # "/The Matrix (1999).mkv" under the "Movies" source -> "smb://192.168.1.10/Media/Movies/The Matrix (1999).mkv"
    return root.rstrip("/") + share_relative

The HT app & writes

WriteWith the HT app closed
favorite, lock, addToCollection, update/detail, UploadFileApplied
update/image ?path=Applied on some builds; on others, only once the app starts
removeFromCollection, addToAlbum, removeFromAlbumAnswers success; nothing changes
rematch saveRefused (804)
markAsWatchedNot applied

Watched & resume points

No endpoint reads or writes clearlogos. The player takes them from the scrape or from PNG files next to the video:

Known issues

Behavior on current HT builds (5.1.05–5.1.08) that may change in future updates:

IssueWorkaround
TVDb search returns no resultsMatch by TMDB, or with setPathnameMatch (confirm the result)
Backdrops chosen on the TV for a series or season override later API changes, though API reads show the new pictureNone known
Season backdrops can't be read or setNone
getEntry lists leftover entries and omits user-added collection membersSee getEntry
getAlbumsWithAggregation reports wrong membershipsUse getAlbumAggregations
Category writes dropped when sent close togetherRead back and re-send
disbandCollection fails on an empty collectionDelete it from the TV
Titles occasionally drop out of every listing (while getDetail still serves them) after several collection changes in a rowRematch the title's file to the same title
Titles imported from NFO files lose the links through their people: choosing a director or cast member doesn't list that person's other titlesRematch the title to itself
Genre list can contain same-name duplicatesNone via the API
Some seasons answer 803 on getDetail after a library rebuildRematch the episodes
No API to start a library scan or rescan one folderRescan from the TV; use NFO files to steer matching

Changelog

Guide versionDateChanges
1.1.22026-10-03"About this guide" notice: unofficial, based on testing, can change with any HT or firmware release, not Zidoo-approved; a link back to z-keeper.com.
1.1.12026-10-03Blu-ray folders are registered as one file whose path is the folder.
1.1.02026-10-03An expandable example on every endpoint: call, real response, explanation and Python for using the result. Corrections: episodes do have last-watched dates (on their file rows in season detail); season detail reports the current episode; getEntry file children carry playPoint; getImages returns logos and local pictures; getCollectionList includes series; the search response shape; which calls report source vs. share-relative paths; the NFO issue concerns links through people.
1.0.22026-10-03Corrected when a resume point marks a file watched: within 5 minutes of the end or at 93%, whichever comes first (was "99.2%"). The Bookmark Start/End settings don't apply to API writes.
1.0.12026-10-03The HT app closing itself and the player's screen-off sleep are described as occasional, not routine; the sleep keeps the front-panel clock lit and wakes with the remote's power button.
1.02026-10-03First edition. Covers HT 5.0.68 through 5.1.08.