Локальні API замість вендорської хмари: медіатека
Друга частина серії за каталогом debloat.dev. Тема — фото, музика, відео. Не «ще один медіаплеєр із гарним UI», а медіатека, до якої можна написати запит: знайти всі фото без геотегу, кинути альбом у чергу програвання, промотати серіал на іншому пристрої, зібрати статистику прослуховувань — зі скрипта, а не пальцем по апці.
Спільний знаменник усіх пʼяти — сервер уже тримає базу, індекс і транскодер; питання лише в тому, чи пускає він до цього по HTTP, і на яких умовах. Пускає всюди — але автентифікація в кожного своя, і саме на ній ламаються перші спроби.
Рамка: що обрати під задачу
| Що є | Що взяти | Стиль |
|---|---|---|
| Свій медіасервер (фільми/серіали/музика), треба керувати ним зі скрипта | Jellyfin REST | HTTP REST |
| Kodi на телику/приставці, хочу «пульт своїми руками» | Kodi JSON-RPC | JSON-RPC над HTTP/TCP |
| Музична колекція, потрібна сумісність із купою готових плеєрів | Navidrome (Subsonic) | Subsonic + Native |
| Фотоархів замість Google Photos, треба автоматика над фото | Immich | OpenAPI REST |
| Фотоархів із фокусом на пошук і батч-операції | PhotoPrism | REST /api/v1 |
Jellyfin (REST)
Що замінює. Хмару Plex і будь-який вендорський медіастрімінг: Jellyfin повністю локальний і віддає весь каталог по HTTP REST. Той самий API, яким ходять офіційні клієнти, відкритий і для скриптів — без окремого «developer plan», токенів OAuth і rate-лімітів провайдера. Одна адмінська API-ключ-стрічка — і доступний увесь бібліотечний граф: бібліотеки, айтеми, сесії, юзери.
Транспорт і авт. HTTP(S), порт 8096 (HTTPS 8920). Ключ — у дашборді: Dashboard → API Keys → «+». Далі два формати заголовка, обидва робочі:
Authorization: MediaBrowser Token="8f3b...api_key"
або легасі-варіант X-Emby-Authorization: MediaBrowser Client="script", Token="8f3b...". Для повного запиту обовʼязкові лише Client і Token; Device/DeviceId/Version опційні. Найпростіше — узагалі query-параметром ?api_key=8f3b.... У Jellyfin 10.11 зʼявилась опція вимикати легасі-методи авт (X-Emby-*) — вони заплановані на видалення, тож у свіжих інсталяціях канон — заголовок Authorization: MediaBrowser.
Робочий приклад.
KEY=8f3b1c9e5d7a4b2f0e6c8a1d3f5b7e9c
HOST=http://jellyfin.local:8096
# усі бібліотеки-корені
curl -H "Authorization: MediaBrowser Token=\"$KEY\"" \
"$HOST/Items?Recursive=false"
# усі фільми, тільки id+назва, без картинок
curl -H "Authorization: MediaBrowser Token=\"$KEY\"" \
"$HOST/Items?IncludeItemTypes=Movie&Recursive=true&Fields=Path&enableImages=false"
# репорт про програвання (playback reporting)
curl -X POST -H "Authorization: MediaBrowser Token=\"$KEY\"" \
-H "Content-Type: application/json" \
-d '{"ItemId":"a1b2c3","PositionTicks":0}' \
"$HOST/Sessions/Playing"
Айтеми фільтруються IncludeItemTypes (Movie, Episode, Audio, Series), ParentId, Recursive=true, Fields=... для довантаження полів. Прогрес репортиться трійкою POST /Sessions/Playing → /Sessions/Playing/Progress → /Sessions/Playing/Stopped.
Сценарій. Нічний скрипт тягне GET /Items?IncludeItemTypes=Movie&Fields=Path,DateCreated, звіряє з файловою системою, і рапортує в телеграм про фільми, які лежать на диску, але випали з індексу. Плюс окремий вебхук через плагін jellyfin-plugin-webhook (Generic Destination) б’є на власний endpoint при кожному PlaybackStart — без polling.
Граблі.
- З адмінським API-ключем айтеми віддаються без UserData (позначки «переглянуто», позиція) — це прив’язано до юзера, а ключ безюзерний. Треба watched-стан — автентифікуватись як користувач через POST /Users/AuthenticateByName і ходити з юзер-токеном та ?userId=.
- PositionTicks — у тіках по 100 нс (10 000 000 на секунду), не в мілісекундах. Класична помилка в репортингу прогресу.
- Порядок і набір полів залежать від версії; Fields доводиться перечитувати при апгрейді мажора.
Джерела: api.jellyfin.org, github.com/jellyfin/jellyfin-plugin-webhook.
Kodi (JSON-RPC)
Що замінює. Пульт, апку-компаньйон і IR-приймач. Kodi віддає повний JSON-RPC 2.0 над двома транспортами, і це найзручніша річ у всій добірці для «пульта своїми руками»: одна команда Player.PlayPause замінює цілий фізичний девайс, а Application.SetVolume — крутилку гучності, до якої не треба тягтися.
Транспорт і авт. Два канали під дві задачі. HTTP — POST /jsonrpc на веб-порту (дефолт 8080), Basic Auth логіном/паролем із Settings → Services → Control; годиться для разових команд зі скрипта чи cron. TCP raw socket на порту 9090 (той самий порт віддає й WebSocket ws://host:9090/jsonrpc) — персистентний канал, який ще й пушить нотифікації (Player.OnPlay, Player.OnStop) без опитування. HTTP і TCP — різні порти, це джерело плутанини №1: /jsonrpc живе на 8080, не на 9090.
Робочий приклад.
# HTTP: що зараз грає, і пауза
curl -u kodi:kodi -X POST http://kodi.local:8080/jsonrpc \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"Player.GetActivePlayers"}'
# -> {"result":[{"playerid":1,"type":"video"}]}
curl -u kodi:kodi -X POST http://kodi.local:8080/jsonrpc \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":2,"method":"Player.PlayPause","params":{"playerid":1}}'
# гучність на 40%
curl -u kodi:kodi -X POST http://kodi.local:8080/jsonrpc \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":3,"method":"Application.SetVolume","params":{"volume":40}}'
Персистентний канал із пушем — по TCP-сокету:
import socket, json
s = socket.create_connection(("kodi.local", 9090))
s.sendall(json.dumps({
"jsonrpc": "2.0", "id": 1, "method": "Player.GetActivePlayers"
}).encode())
print(s.recv(4096)) # відповідь на запит
# далі сокет сам віддаватиме {"method":"Player.OnPlay",...} при подіях
Родини методів: Player.* (Open/PlayPause/Stop/Seek/GetItem), Playlist.* (Add/Insert/Clear/GetItems), Application.* (SetVolume/SetMute/Quit), GUI.*, VideoLibrary.*.
Сценарій. Домашній «пульт» — телеграм-бот тримає TCP-сокет на 9090, ретранслює кнопки в Player.PlayPause/Playlist.Add, а нотифікації Player.OnPlay шле назад у чат («зараз грає: …»). Один персистентний конект, нуль polling.
Граблі.
- TCP-сокет на 9090 не має власної автентифікації — його захищає лише те, що порт слухає в LAN. У інтернет не світити; для віддаленого доступу — HTTP-порт за реверс-проксі з Basic Auth, або VPN.
- Порт 9090 треба явно ввімкнути (Settings → Services → «Allow programs on other systems to control Kodi»), і на деяких збірках (LibreELEC) він гасне після рестарту, поки не зафіксуєш у advancedsettings.xml.
- Raw-TCP чутливий до парсингу: великі відповіді приходять фрагментами, треба буферизувати до валідного JSON, а не читати одним recv().
Джерела: kodi.wiki/view/JSON-RPC_API, kodi.wiki/view/JSON-RPC_API/v13.
Navidrome (Subsonic + Native)
Що замінює. Хмарний стрімінг музики. Navidrome реалізує Subsonic API, тож до нього одразу говорить величезна екосистема готових плеєрів (DSub, Symfonium, play:Sub) — але той самий API відкритий і скрипту. Плюс власний Native API для того, чого в Subsonic нема. Правило вибору просте: Subsonic — коли треба сумісність і базові операції (списки, стрім, зірочки); Native — коли треба те, що Subsonic не покриває (керування самим Navidrome, детальні плейлисти, адмінка).
Транспорт і авт. HTTP, порт 4533. Subsonic-авт — це той класичний salt+token, на якому спотикаються всі. Схема (з API 1.13.0):
- згенерувати випадковий salt
s— рядок щонайменше 6 символів, новий на кожен запит; - порахувати
t = md5(password + salt)— 32 hex-символи в нижньому регістрі; - слати
tіsяк query-параметри. Пароль у відкритому вигляді по дроту не йде взагалі.
Канонічний приклад із специфікації: пароль sesame, salt c19b2d → md5("sesamec19b2d") = 26719a1196d2a940705a59634eb18eab. Обовʼязкові при кожному виклику ще u (юзер), v (версія протоколу), c (імʼя клієнта), f=json (інакше віддасть XML).
Робочий приклад.
# перевірка salt+token з канонічним прикладом зі специфікації:
# md5("sesame" + "c19b2d") = 26719a1196d2a940705a59634eb18eab
echo -n "sesamec19b2d" | md5sum # -> 26719a1196d2a940705a59634eb18eab
# реальний виклик: свій salt на кожен запит
U=mark; PASS=sesame
S=$(head -c16 /dev/urandom | od -An -tx1 | tr -d ' \n') # випадковий salt
T=$(printf '%s' "${PASS}${S}" | md5sum | cut -d' ' -f1)
BASE="http://navidrome.local:4533/rest"
COMMON="u=$U&t=$T&s=$S&v=1.16.1&c=myscript&f=json"
# 20 найновіших альбомів
curl "$BASE/getAlbumList2?type=newest&size=20&$COMMON"
# стрім треку за id (id беруться з getAlbumList2 -> getAlbum)
curl "$BASE/stream?id=300000123&maxBitRate=192&format=mp3&$COMMON" -o track.mp3
Native API — інша авторизація: POST /auth/login з {"username","password"} → JWT у полі token, далі заголовок x-nd-authorization: Bearer <jwt> на /api/song, /api/playlist тощо.
Сценарій. Скрипт «розігрій колонку» о 8-й ранку: getAlbumList2?type=frequent, вибирає випадковий альбом, тягне getAlbum за трек-id і кидає stream у локальний mpv. Salt — свіжий на кожен із запитів, інакше сервер має право відхилити.
Граблі.
- Salt мусить бути випадковим і одноразовим. Захардкодити один salt «щоб працювало» — і вся ідея token-авт втрачається: перехоплений t+s стає багаторазовим. Мінімум 6 символів — вимога специфікації.
- md5 тут не про безпеку пароля, а про те, щоб не слати його плейнтекстом; канал усе одно має бути HTTPS, якщо виходить за LAN.
- Історично в Navidrome була CVE-2025-27112 — обхід авт у Subsonic API з неіснуючим юзернеймом. Тримати версію свіжою, особливо якщо порт 4533 дивиться далі за локалку.
- f=json не забувати — дефолт xml, і парсер на JSON мовчки отримає XML.
Джерела: subsonic.org/pages/api.jsp, navidrome.org/docs/developers, CVE-2025-27112.
Immich (OpenAPI)
Що замінює. Google Photos і хмарний бекап фото. Головна перевага для автоматизації — Immich віддає повну OpenAPI 3.0-специфікацію, тобто клієнт до нього не пишеться руками, а генерується зі схеми: типізований SDK будь-якою мовою за одну команду. Це якісно інший рівень, ніж реверсити відповіді камери.
Транспорт і авт. HTTP(S), порт 2283. Ключ — у вебі: Account Settings → API Keys → New API Key. Далі всюди заголовок x-api-key: <ключ> (не Bearer, не Basic — саме x-api-key). Жива Swagger-дока інсталяції — на /api/api-docs, канонічна публічна — на api.immich.app; машинна схема — файл immich-openapi-specs.json у репозиторії.
Робочий приклад.
KEY=Zx8Qw3n1pR7kLmV2tYbN6cDf
HOST=http://immich.local:2283
# пошук по метаданих (дата, камера, гео, тип)
curl -X POST "$HOST/api/search/metadata" \
-H "x-api-key: $KEY" -H "Content-Type: application/json" \
-d '{"takenAfter":"2026-01-01T00:00:00.000Z","type":"IMAGE"}'
# смарт-пошук (CLIP, natural language)
curl -X POST "$HOST/api/search/smart" \
-H "x-api-key: $KEY" -H "Content-Type: application/json" \
-d '{"query":"cat on a red sofa","size":25}'
# альбоми
curl -H "x-api-key: $KEY" "$HOST/api/albums"
Генерація типізованого клієнта зі схеми:
npx @openapitools/openapi-generator-cli generate \
-i http://immich.local:2283/api/api-docs \
-g python -o ./immich-client
POST /api/search/smart бере query, page, size, withExif; POST /api/search/metadata — структурні фільтри (takenAfter/Before, type, city, make, isFavorite). Асети — /api/assets, альбоми — /api/albums.
Сценарій. Скрипт тижневого прибирання: search/metadata з {"isNotInAlbum":true,"type":"IMAGE"}, групує знайдене за датою через search/smart і розкладає в альбоми POST /api/albums — усе поверх згенерованого SDK, без ручного парсингу JSON.
Граблі.
- Immich дуже швидко рухається і сам попереджає, що API до стабільного 1.0 може ламати сумісність між релізами. Рятунок вбудований: перегенерувати клієнт зі свіжої схеми після кожного апгрейда — і зміни вилазять на етапі компіляції, а не в рантаймі.
- x-api-key — це заголовок, а не Authorization: Bearer. Переплутати — 401.
- Дати в пошуку — ISO 8601 з мілісекундами й Z (2026-01-01T00:00:00.000Z); скорочений формат сервер не завжди приймає.
Джерела: docs.immich.app/api, api.immich.app, github.com/immich-app/immich.
PhotoPrism (REST /api/v1)
Що замінює. Google Photos із фокусом на пошук і батч-обробку локального фотоархіву. PhotoPrism дає простий REST /api/v1, заточений під «знайти за запитом і зробити щось із пачкою» — архівувати, зробити приватними, видалити сотні фото одним викликом.
Транспорт і авт. HTTP(S), порт 2342. Дві опції. Класична — сесія: POST /api/v1/session з логіном/паролем віддає токен, далі заголовок X-Auth-Token: <token> (або Authorization: Bearer <token>). Простіша для скриптів — app password: заводиться в UI (Settings → Account → Apps and Devices) і йде прямо як Authorization: Bearer <app-password> без кроку створення сесії.
Робочий приклад.
HOST=http://photoprism.local:2342
# 1) сесія -> токен
TOKEN=$(curl -s -X POST "$HOST/api/v1/session" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"insecure"}' \
| python3 -c 'import sys,json; print(json.load(sys.stdin)["access_token"])')
# 2) пошук фото за запитом
curl -H "X-Auth-Token: $TOKEN" \
"$HOST/api/v1/photos?q=dog&count=50"
# 3) батч: заархівувати список за UID
curl -X POST "$HOST/api/v1/batch/photos/archive" \
-H "X-Auth-Token: $TOKEN" -H "Content-Type: application/json" \
-d '{"photos":["psh1abc23","psh4def56"]}'
Пошук GET /api/v1/photos бере q (пошуковий рядок із фільтрами color:, country:, year:), count, offset. Батч-родина — POST /api/v1/batch/photos/{archive,private,restore,delete} з масивом UID у полі photos.
Сценарій. Скрипт «приховати весь скриншот-мотлох»: GET /api/v1/photos?q=screenshot&count=1000, збирає UID у список і одним POST /api/v1/batch/photos/private ховає всю пачку з основної стрічки — за один раунд-тріп замість тисячі кліків.
Граблі.
- PhotoPrism не має офіційної deprecation-політики — маршрути й параметри можуть змінитися між релізами без гарантій. Пінити версію там, де скрипт критичний.
- Токен сесії має термін життя; app password для довгих скриптів надійніший — його не треба переоформлювати після протухання сесії.
- Батч-ендпоінти чекають UID фото (psh...), а не числові id чи імена файлів; UID беруться з відповіді /photos.
Джерела: docs.photoprism.app/developer-guide/api, github.com/photoprism/photoprism.
Спільні граблі всіх пʼяти
- Авт у кожного своя — і перша спроба ламається саме на ній. Jellyfin —
MediaBrowser Token, Kodi — Basic Auth (а TCP взагалі без неї), Navidrome — salt+token, Immich —x-api-key, PhotoPrism — сесія/Bearer. Одна схема не переноситься на іншу службу; тримати шпаргалку. - Salt має бути одноразовим. Це не деталь Navidrome — це весь сенс token-авт. Захардкоджений salt = багаторазовий токен = дірка.
- Тіки, мілісекунди, UID, ISO-дати. Кожен сервер має свою одиницю (Jellyfin — 100-нс тіки, PhotoPrism — UID
psh..., Immich — ISO зZ). Половина 400-х — це неправильна одиниця, а не неправильний ендпоінт. - Локальний ≠ доступний лише вдома. Порт 9090 Kodi і 4533 Navidrome без фаєрвола видно з усього LAN, а за кривим проксі — і з інтернету. Ключ/токен = діючий доступ; не в git, не в публічний порт без потреби.
- Джерело правди — жива схема інсталяції.
/api/api-docs(Immich),/swagger,immich-openapi-specs.json, конкретна версія Kodi JSON-RPC — а не стаття. Ця теж.
Порівняльна таблиця
| Проєкт | Стиль API | Авт | Офіційна OpenAPI-схема | Стабільність між мажорами |
|---|---|---|---|---|
| Jellyfin | HTTP REST | MediaBrowser Token (API key / user token) |
так (ReDoc/Swagger) | помірна; легасі-заголовки під видалення (10.11+) |
| Kodi | JSON-RPC 2.0 (HTTP + TCP/WS) | Basic Auth на HTTP; TCP без авт | так (introspect, версіонований) | висока; версія протоколу пінується (JSONRPC.Version) |
| Navidrome | Subsonic (+ Native REST) | salt+token (Subsonic); JWT (Native) | Subsonic — ні (заморожена spec-сторінка); Native — часткова | Subsonic-контракт стабільний; сам Navidrome рухається |
| Immich | OpenAPI 3.0 REST | x-api-key |
так, машинна (immich-openapi-specs.json) |
низька до 1.0 — ламає між релізами, рятує кодогенерація |
| PhotoPrism | REST /api/v1 |
сесійний токен / app password (Bearer) | часткова (Swagger є, без гарантій) | низька — офіційної deprecation-політики нема |
Серія
- Частина 1 — розумний дім і IoT: ESPHome, Home Assistant, diyHue, Homebridge, pytapo → /claude/debloat-api-home/
- Частина 3 — робочий стіл і продуктивність: локальні API десктопних інструментів → /claude/debloat-api-desk/
Стрімінговий сервіс каже «твоя бібліотека» про плейлист, який зникне того дня, коли з каталогу заберуть лейбл, а тебе про це навіть не спитають. Слово «твоя» тут означає «доступна тобі, поки ми не передумали».
Медіасервер із відкритим API — це та сама бібліотека, до якої можна написати curl і отримати список файлів, що фізично лежать на твоєму диску. Різниця вилазить не в день, коли все працює однаково зручно, а в день, коли улюблений альбом тихо щезає зі стрімінгу — а в Navidrome він як лежав під stream?id=..., так і лежить. Ціна питання — розібратися, що salt має бути одноразовим, і що тіки Jellyfin — по сто наносекунд.