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.
- 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
- Base URL:
http://<player-ip>:9529. No authentication. Every call is an HTTP GET except the artwork upload (multipart POST). - The Home Theater app (package
com.zidoo.poster) owns the movie and TV library. It updates separately from the player firmware, and several endpoints depend on its version. - Coverage: this guide covers the library API (titles, files, matching, collections, categories, artwork, play state) plus the device and file endpoints you need around it.
- How it was verified: behavior was tested on real players. Where models or HT versions differ, the guide says so. Unverified points are marked as such.
- Labels:
- NEWendpoints added in HT 5.0.98 and 5.1.x. Detect them before use (see HT versions).
- CLASSICendpoints present on all HT5 builds, most of them used by the player's own web page.
Official documentation
| Link | Covers |
|---|---|
| Zidoo Developer Platform: Files | File Control API: storage devices and folder listings |
| Zidoo Developer Platform: Movies | The 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 status | Meaning |
|---|---|
| 200 | OK (for classic writes, "accepted", not "done") |
| 804 | A catch-all. It can mean any of these:
|
| 803 | "Data empty or incorrect": the entry exists but this route has nothing to serve for it |
| 805 | A 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
| type | Entry |
|---|---|
| 0 | A file. Every movie and episode has one or more file entries beneath it. Unmatched files are also type 0. |
| 1 | Movie |
| 2 | Collection created by scraping (a TMDB set). Members point to it with parentId. It dissolves when a removal leaves one member. |
| 3 | Series |
| 4 | Season. A show with a single season is listed as its season. |
| 5 | Episode |
| 6 | Collection 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.
| ID | Where to get it | Taken by |
|---|---|---|
| Entry ID | id on list rows, detail and edit records | Detail and edit reads, metadata writes, favorite, lock, artwork writes and upload, adding to and removing from collections, all category calls |
| File ID | edit record videos[].id (equal to detail aggregations[].id), or fileId from a path lookup | setPlayPoint, markAsWatched, per-file rematch |
| Collection ID | getCollectionList | getCollection, addToCollection, disbandCollection |
| Category ID | getAlbums | All category calls |
aggregationId field / VideoInfo ID | List rows; detail aggregations[].aggregation.id | Avoid. These are different namespaces whose numbers overlap with entry IDs. |
File paths
- The path endpoints expect the source URL spelling:
smb://<server>/<share>/Movies/Example (2020).mkv(or the equivalentnfs://). A path relative to the share answers 404. - The edit record (
videos[].path) andgetEntry(path) report files in the source spelling. Title detail (getDetail) reports them share-relative, inuri(/Movies/The Matrix (1999).mkv). Convert between the two with the source URLs fromgetSources. - Blu-ray folders (a folder holding
BDMV\, and usuallyCERTIFICATE\) are registered as one file whose path is the folder itself, e.g.smb://<server>/<share>/Movies/Example (2020). There's no extension and no trailing slash. The.m2tsand.bdmvfiles inside aren't registered separately. The file'sdurationis the main playlist's. - DVD folders (a folder holding
VIDEO_TS\) are registered the same way: one file whose path is the folder, with the.VOBand.IFOfiles inside not registered separately. The entry and its file carryisDvd: trueandisBluRay: false, and the file'sdurationis 0. A DVD saved as an.isoimage is tagged differently:isBluRay: true,isDvd: false. ReadisBluRayas "disc image or Blu-ray folder", not as a resolution. - Source URLs from
getSourcesinclude the share's user and password as query parameters. Strip them before storing or logging a path.
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.
- Library endpoints (getEntry and the path calls): call
GET /ZidooPoster/v2/getEntry?id=1&start=0&count=1. Status 200, 400, 404 or 422 means they're available; 804 means an older HT. - getAlbumMembers: detect it separately the same way, since it arrived one build later.
- Re-check after a power cycle: a player that was off when you checked will look like it lacks them.
| Endpoint | Available from |
|---|---|
v2/setPlayPoint | HT 5.0.98; use versionCode 5099 or later (earlier 5098 builds expect a different ID) |
v2/getEntry, v2/getIdByPathname, v2/getIdsByPathname, v2/setPathnameMatch, v2/changeMatchDirect | HT 5.1.03 (versionCode 5102) |
v2/getAlbumMembers | The HT build after 5.1.03 |
Read the installed HT version from /ZidooControlCenter/Apps/getApps (package com.zidoo.poster).
Recommended practices
- 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.
- 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.
- 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.
- Never write to a stored ID. Before writing, confirm the ID still names the same title:
getEntryon HT 5.1.03+, otherwise the edit record. If it doesn't, find the title's current ID through its files' paths (getIdsByPathname). - Pair titles across players by file path or provider ID, never by entry ID or name.
- 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.
- 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
- Returns:
status,model,firmware,ip,net_mac(Ethernet),wif_mac,duuid, and capability flags such asableRemoteSleep.net_macandduuididentify 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"]}')
- Returns:
apps[{label, packageName, versionName, versionCode}]. The HT app iscom.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
- 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
Key.PowerOn.Poweroffpowers the player off. It stops answering within 2–6 s.Key.PowerOn.Standbyonly 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.
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.
- 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"])
- Returns:
hosts[{ip, name}]. Despite its name,ipholds 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"
- Returns:
{status, isExists, filelist[{name, path, type, length, modifyDate, isBDMV, isBluray}]}. Entrytype: 0 folder, 2 video. typeis required; without it the answer is 805.- For an ordinary folder,
typedoes not filter the listing; read each entry's owntype. - For a network host's root,
type=0answersisExists: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
Equivalent forms:
/Poster/v2/getFilterAggregations?page=&pagesize=&type=0(rows underarray);/Poster/getVideoList?type=movie&page=&rows=;/filter?source=-1&videoType=<v>&genre=-1&year=&sort=0&page=&count=, wherevideoTypeis −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:
yearcan be a string, a number, or absent.collectionIdon 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
pageand repeat page 1, so also stop when a page brings no new IDs.
- Keep
- 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
- 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")}
- 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"])
- Returns:
{status, key, all:[{keyName, aggregation}], allSize, movieSize, tvSize, collectionSize}. Each hit'saggregationis 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
- 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, andaggregations[]: one row per file for a movie, one per episode for a season. - Each row nests its file's details (
uri,playPoint,lastWatchTime,addedTime).positionappears on the root and on each row. - TV detail adds a
tv{id, name, tmdbId, posterPath, backdropPath}block;tv.nameis the series name. - In the metadata block,
genres,directorsandactorsare space-separated ID strings.
- The title, an
- 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,currentWatchEpisodeNumberandprogress. - 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 notmdbId. A TMDB-matched show exposestmdbIdwithepisodeId:-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")
The edit record. It's the only read that answers for every entry type except seasons.
- Returns:
id,type,name,parentId(scraped collection) andcollectionId(user collection);favor,watched,lock, and the quality tagsisBluRay,is3d,is4k,isFHD,isDvd,is2k,isHD;genres[], anddirectors[]/actors[]with local person IDs;aggregation{…};videos[]: one row per file,{id, path, position, duration, watched, favor}, with nolastWatchTime;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 orvideos. 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"]}
- Returns:
aggregation{dataType, tmdbId, tvdbId, imdb, seasonNumber, tvName, name}. - Collections: a scraped collection's
aggregation.tmdbIdis 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:
dataType13 is a show. It carriestvdbIdandimdbif matched on TVDb, ortmdbIdif matched on TMDB (rarely both).dataType1 is a season listed on its own; theretmdbIdis 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
- 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 carriesplayPoint, its resume point),total,hasMore.
id=0answers 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:
tmdbIdis the show's TMDB ID; the episode's own istmdbEpisodeId. - Provider IDs are only those of the provider the title was matched with; the others are null.
- 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:1while 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
childreninclude only members that belong to it throughparentId(scraped members). Members added by the user aren't listed; usegetCollection.
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)
- Returns:
{status, code, id, type, fileId, videoInfoId, matched}.idis the movie or episode holding the file;fileIdis the file entry (the ID play-state calls take). - For a registered but unmatched file,
matchedis false andidis 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"]
- Returns:
{status, results:{"<path>":{status, code, id, type, fileId, videoInfoId, matched}}, map:{"<path>":id}}. A path with no registered file hasstatus404 insideresults. - 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
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}, withordertaken from the edit record'sconfig.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.typesis 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
tagsays 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, sendprofilePathas the string"null". Always send both arrays. runTimeis 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.
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"
- TMDb person search:
results[{id, name, profile_path, known_for_department}]. These are TMDb IDs; send them with a non-zerotag.
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
- 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
- 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"]
- 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
setPlayPointinstead.
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"])
- 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"]
- ID: the file ID:
videos[].idfrom the edit record, orfileIdfrom 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=0clears the resume point, the last-watched time and the watched flag.
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
- 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_BUSYwhile the library is scanning. Wait for the scan (seegetSourcesscanStatus) 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 oftmdbIdmatches 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.
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)
- 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")
The flow the player's web page uses, and the only matching method before HT 5.1.03.
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 inimageis the most reliable way to tell results apart. - Movie and TV results are mixed together; filter on
isTv.
GET /ZidooPoster/v2/rematch/match?api=&id=<ID>&index=<i>&target=<JSON>&dataId=→data.adjusts[{operate, seasonNumber, episodeNumber, name, isTV}]. TV replies omittype; take it from the result'sisTv.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 outimagewhen it ends innull: the player tries to download it and the save fails.adjusts: send back every field you received; dropping one fails with 804. For TV, sendoperate=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
- 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
- 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
- A title can be in at most one scraped collection (type 2, via
parentId) and one user collection (type 6, viacollectionId). 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.
- 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}
- 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}. Thatvideoinfo.idis 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]
- Creates a user collection containing one title. It appears in
getCollectionListshortly 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
aggregationIdtakes the title's entry ID.- If the collection ID no longer exists, the player creates a new collection instead of failing. Re-read
getCollectionListbefore 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
- 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
collectionIdreads −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)
- Deletes a collection; the titles remain. There is no rename endpoint: create a new collection and move the titles.
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.
- 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"]}
- 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)]
- 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 ownstatus. - Collections are never listed themselves; use
getAlbumAggregationsto 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")
aggregationIdtakes 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.
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)
- Creates a category containing one title. Re-read
getAlbumsfor 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")
- 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"])
{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
- 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"])
- Sets artwork from a TMDB image path (e.g.
/abc123.jpg). Onlyposterandbackdropexist; 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
- Uploads your own picture:
multipart/form-datawith one field namedfile(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/backdropPaththe player reports doesn't change after an upload, so to confirm one, compare the image itself.
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
- 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
- Returns: an array of library sources,
[{id, name, url, matchedCount, exist, connect, scanStatus}]. urlincludes 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
- The HT app may close itself after a period without use. This has been seen on older HT builds, after about an hour. It isn't consistent: on recent builds the app has stayed open overnight. Don't assume either way; if writes start failing, start the app again.
- The player can go into a screen-off sleep after hours without use. The picture goes black but the front panel keeps showing the clock, unlike a real power-off. The remote's power button brings it back, still in the HT app. It doesn't happen every time.
- Reads work with the app closed. A library scan doesn't add new files while the app is closed.
- No endpoint reports whether the app is running. Start it with
openAppbefore a batch of writes, and again if writes start failing.
| Write | With the HT app closed |
|---|---|
| favorite, lock, addToCollection, update/detail, UploadFile | Applied |
update/image ?path= | Applied on some builds; on others, only once the app starts |
| removeFromCollection, addToAlbum, removeFromAlbum | Answers success; nothing changes |
| rematch save | Refused (804) |
| markAsWatched | Not applied |
Watched & resume points
- Through the API, a file is either watched or has a resume point.
setPlayPointwith a point short of the end clears watched.markAsWatchedparks the resume point as a negative value and sets the last-watched time to now.- A negative point is refused.
- Finished files keep their position at the end. When playback ends,
positionis left equal toduration. Watched is tracked separately, so a file at the end isn't necessarily marked watched. - When a resume point counts as watched: the player marks a file watched once the point is 5 minutes or less from the end, or 93% or more through, whichever comes first.
A resume point copied to another player within that range makes the file watched there, not resumable. One 3D title only counted as watched at its exact end; treat 3D titles as unverified.File length Watched from 9 minutes 5:00 before the end (44%) 15 minutes 5:00 before the end (67%) 116 minutes 8:06 before the end (93%) 165 minutes 11:33 before the end (93%) - Last-watched dates are kept per file, for movies and episodes alike, and read from
getDetail:- Movie:
aggregation.aggregations[].aggregation.lastWatchTime. - Episode: in its season's detail,
aggregation.aggregations[] (episode) → aggregations[] (file) → aggregation.lastWatchTime.
getEntrydon't include them. −1 means never. Any play-state write without an explicitlastWatchTimerecords "now". - Movie:
- No bulk read: there's no endpoint listing resume points or watch history. Read each title's edit record (
videos[]) orgetEntry(children'splayPoint). With four requests at a time, a library of 2,000 titles takes one to two minutes. For dates, read movie and season details.
Clearlogo
No endpoint reads or writes clearlogos. The player takes them from the scrape or from PNG files next to the video:
- Movies:
<video file name>-clearlogo.pngbeside the video (800×310 recommended). - TV:
clearlogo.pngin every folder that contains episode files (each season folder). - It must be a PNG with transparency. Shows immediately, no rescan needed.
- A logo from the scrape takes priority, and a rematch downloads it again.
- Requires the clearlogo option in the HT settings.
Known issues
Behavior on current HT builds (5.1.05–5.1.08) that may change in future updates:
| Issue | Workaround |
|---|---|
| TVDb search returns no results | Match 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 picture | None known |
| Season backdrops can't be read or set | None |
getEntry lists leftover entries and omits user-added collection members | See getEntry |
getAlbumsWithAggregation reports wrong memberships | Use getAlbumAggregations |
| Category writes dropped when sent close together | Read back and re-send |
disbandCollection fails on an empty collection | Delete it from the TV |
Titles occasionally drop out of every listing (while getDetail still serves them) after several collection changes in a row | Rematch 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 titles | Rematch the title to itself |
| Genre list can contain same-name duplicates | None via the API |
Some seasons answer 803 on getDetail after a library rebuild | Rematch the episodes |
| No API to start a library scan or rescan one folder | Rescan from the TV; use NFO files to steer matching |
Changelog
| Guide version | Date | Changes |
|---|---|---|
| 1.1.3 | 2026-10-04 | DVD folders (VIDEO_TS) are one file at the folder path, tagged isDvd with a duration of 0; a DVD .iso is tagged isBluRay instead. |
| 1.1.2 | 2026-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.1 | 2026-10-03 | Blu-ray folders are registered as one file whose path is the folder. |
| 1.1.0 | 2026-10-03 | An 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.2 | 2026-10-03 | Corrected 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.1 | 2026-10-03 | The 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.0 | 2026-10-03 | First edition. Covers HT 5.0.68 through 5.1.08. |
Z-Keeper