ประโยคเดียวถึงไมโครโฟนประจำห้อง แล้วทีวีเครื่องที่ถูกต้องก็ตื่น Moonfin ขึ้นมาด้านหน้า และตอนที่คุณดูค้างไว้ก็เล่นต่อจากจุดที่หยุด — โดยตรวจว่าเล่นจริงแล้วก่อนที่ผู้ช่วยจะพูดสักคำ หน้านี้คือสูตรฉบับสมบูรณ์: สถาปัตยกรรม พฤติกรรมของไคลเอนต์ที่วัดแล้วซึ่งกำหนดรูปร่างของมัน และโค้ดฉบับเต็มที่ลบข้อมูลส่วนตัวแล้ว พร้อมปรับใช้กับบ้านของคุณ
ห้าทอด แต่ละทอดมีหน้าที่เดียว เอเจนต์เสียงเลือกชื่อเรื่องและห้อง ทุกอย่างหลังจากนั้นทำงานแบบกำหนดแน่นอน
| ทอด | เกิดอะไรขึ้น |
|---|---|
| 1 · เสียง | ไมโครโฟนประจำห้องได้ยิน “เปิด Castle บนทีวี” เอเจนต์เสียงในบ้านเลือกเครื่องมือและกรอกสองฟิลด์: title และ target |
| 2 · สคริปต์ HA | script.play_jellyfin — เครื่องมือที่เอเจนต์มองเห็น คำอธิบายฟิลด์ของมันมีกฎการจัดเส้นทาง (ห้องไหนตรงกับเป้าหมายไหน สะกดชื่อเรื่องอย่างไร) และมันส่งประโยคผลลัพธ์ของสคริปต์กลับเป็นคำตอบที่พูดออกไป |
| 3 · คำสั่งเชลล์ | shell_command.play_jellyfin รันสคริปต์แบบรอผล — Home Assistant รอ เอเจนต์จึงตอบก่อนที่จะรู้ความจริงไม่ได้ |
| 4 · ตัวสคริปต์ | play_jellyfin.sh: หารายการ (ดูต่อ → ซีรีส์ → หนัง → คำใบ้แบบคลุมเครือ) ปลุกทีวี เปิด Moonfin ถ้าจำเป็น ส่งคำสั่งเล่นผ่าน Jellyfin Sessions API และตรวจว่าเล่นจริงก่อนรายงาน |
| 5 · Moonfin | ไคลเอนต์บนทีวีแต่ละเครื่อง Moonfin รองรับการควบคุมระยะไกลของ Jellyfin ทั้งบน Android TV และ tvOS — เหตุผลที่มันเป็นไคลเอนต์ที่เลือกใช้สำหรับบ้านที่สั่งด้วยเสียง |
SupportsRemoteControl: true ทั้งบน Apple TV และ Android TV — ความสามารถที่ทั้งหน้านี้ขี่อยู่ มันเป็นไคลเอนต์ Jellyfin ที่ขัดเกลาดีและพัฒนาอย่างต่อเนื่อง และการเล่นที่เริ่มจากระยะไกลไปลงตรงที่ผู้ช่วยเสียงต้องการพอดี: บนทีวี เล่นต่อได้ ควบคุมได้การเชื่อมต่อ Jellyfin ของ Home Assistant สร้าง media_player หนึ่งตัวต่อเซสชัน และ unique ID ของเอนทิตีฝัง session ID ไว้ — ซึ่งเปลี่ยนทุกครั้งที่ติดตั้งใหม่และทุกครั้งที่ติดตั้งแอปไคลเอนต์ใหม่ ดีสำหรับแดชบอร์ด แต่ผิดรูปร่างสำหรับไปป์ไลน์เสียงที่คุณอยากทำซ้ำในบ้านมากกว่าหนึ่งหลัง
เส้นทางแบบสคริปต์คุยกับ Sessions API ของ Jellyfin โดยตรง และระบุเป้าหมายด้วยชื่อไคลเอนต์ + ชื่ออุปกรณ์ — ข้อความที่คงที่แม้ติดตั้งใหม่ และเหมือนกันทุกบ้านที่ใช้แอปเดียวกัน ผลคือการแยกที่สะอาด: สคริปต์สองตัวที่ไม่เคยเปลี่ยน และไฟล์คอนฟิกเล็ก ๆ หนึ่งไฟล์ที่เก็บทุกอย่างเฉพาะบ้าน การตั้งบ้านใหม่คือ: ดีพลอยสคริปต์ เปิด Moonfin บนทีวีแต่ละเครื่อง รัน --sessions แล้ววางข้อความ
# jellyfin-targets.conf — per-installation target map
#
# THIS IS THE ONLY FILE THAT SHOULD DIFFER BETWEEN SITES.
#
# Sourced by play_jellyfin.sh and jellyfin-transport.sh. Deliberately contains
# no entity IDs derived from Jellyfin session IDs — those change per install
# and per app reinstall. Targets are resolved from /Sessions by client +
# device name.
#
# To fill this in on a new site:
# 1. Open Moonfin on each device.
# 2. Run: ~/scripts/play_jellyfin.sh --sessions
# 3. Copy the exact CLIENT and DEVICE strings into the case block below.
# 4. For LAUNCH_APP_ID (Android TV) use the app deep link. For tvOS,
# LAUNCH_SOURCE is the app name from the Apple TV entity's source_list.
JELLYFIN_URL="${JELLYFIN_URL:-http://192.168.1.20:8096}" # your server
JELLYFIN_USERNAME="${JELLYFIN_USERNAME:-your-user}" # library user
HA_URL="${HA_URL:-http://192.168.1.10:8123}" # Home Assistant
# Sets the per-target variables. Returns 1 for an unknown target.
jellyfin_target() {
case "$1" in
appletv)
SESSION_CLIENT="Moonfin for tvOS"
SESSION_DEVICE="Apple TV"
LAUNCH_ENTITY="media_player.apple_tv"
LAUNCH_METHOD="appletv_source"
LAUNCH_SOURCE="Moonfin"
LAUNCH_APP_ID=""
LAUNCH_REMOTE=""
# Moonfin tvOS is slow twice over: ~17s to surface NowPlayingItem,
# and ~15s reporting IsPaused with a frozen position while it is
# genuinely playing. Measured. Do not lower without re-measuring.
VERIFY_WINDOW=30
ROOM_LABEL="living room"
;;
chromecast)
SESSION_CLIENT="Moonfin for Android TV"
SESSION_DEVICE="Google Chromecast"
LAUNCH_ENTITY="media_player.bedroom_tv"
LAUNCH_METHOD="androidtv_app"
LAUNCH_SOURCE=""
# Use the app's DEEP LINK, not the bare package name. The Android
# TV remote integration wraps bare packages in market://launch,
# which the Play Store may refuse for sideloaded apps. Moonfin
# registers the "moonfin" scheme; an id containing :// is passed
# through as a direct VIEW intent, Play not involved.
LAUNCH_APP_ID="moonfin://"
# Android TV Remote entity for the SAME device: used to send HOME
# before a relaunch, forcing a real background-to-foreground
# cycle. A repeated launch intent at an already-running Moonfin
# is ignored; the resume event is what makes >=2.5.0 reconnect
# and re-post its remote-control capabilities.
LAUNCH_REMOTE="remote.bedroom_tv"
# Measured: surfaces its session in ~6s and reports state honestly.
VERIFY_WINDOW=16
ROOM_LABEL="bedroom"
;;
*)
return 1
;;
esac
return 0
}
# Every target name this site supports, for usage messages.
JELLYFIN_TARGETS="appletv chromecast"
ทุกข้อได้มาจากการวัดไคลเอนต์จริง แทนการเชื่อเอกสาร — และแต่ละข้อถูกเขียนไว้ในสคริปต์เป็นคอมเมนต์ตรงบรรทัดที่มันกำหนดรูปร่าง โค้ดจึงพกเหตุผลของตัวเองไว้
IsPaused: true พร้อมตำแหน่งที่หยุดนิ่งในช่วงราว 15 วินาทีแรกของการเล่นจริง และ Android TV ที่อยู่เบื้องหลังจะเดินหน้าไปแบบมองไม่เห็นก่อนหยุดตัวเอง การอ่านครั้งเดียวแยกสองกรณีนี้ไม่ได้ — การทดสอบจึงเป็นตัวอย่าง “กำลังเล่น” สองครั้งติดกัน: ไคลเอนต์ที่อยู่เบื้องหลังจะเสื่อมลง ส่วนตัวที่เล่นจริงจะคงที่ ตำแหน่งถูกตัดออกโดยตั้งใจ (เคยเห็น tvOS นับถอยหลังขณะกำลังเล่น)AMBIGUOUS แทนการเดา — เล่นผิดห้องแย่กว่าไม่เล่นเลยmoonfin:// ไปถึงแอปโดยตรงในฐานะ VIEW intent การเปิดด้วยชื่อแพ็กเกจเปล่า ๆ จะวิ่งผ่าน Play Store ซึ่งอาจปฏิเสธแอปที่ติดตั้งเอง — deep link ได้ผลทุกครั้ง| ไคลเอนต์ | เซสชันปรากฏใน | การรายงานสถานะ | VERIFY_WINDOW |
|---|---|---|---|
| Moonfin for Android TV | ~6 วินาที | ซื่อตรงตั้งแต่ต้น | 16 วินาที |
| Moonfin for tvOS | ~17 วินาที | รายงานว่าหยุดชั่วคราวราว 15 วินาทีของการเล่นจริง | 30 วินาที |
การเริ่มเย็นตั้งแต่ต้นจนจบ — อุปกรณ์หลับ แอปปิด — วัดได้ 20–25 วินาทีในกรณีแย่ที่สุด: อยู่ในงบสบาย ๆ และเป็นเหตุผลที่ช่วงเวลาของแต่ละเป้าหมายเป็นคอนฟิก ไม่ใช่ค่าคงที่
play_jellyfin.sh ฉบับสมบูรณ์ ลบข้อมูลส่วนตัวเฉพาะชื่อและที่อยู่ REST ล้วน — curl และ python3 ไม่ใช้ SDK ไม่ใช้ venv ข้อมูลลับ (JELLYFINAPI, HA_TOKEN) อยู่ใน ~/.config/secrets.env เปลี่ยนบรรทัดแจ้งเตือนใน fail_loud เป็นช่องทางแจ้งเตือนของคุณเอง
#!/bin/bash
# play_jellyfin.sh - Play Jellyfin content on a Moonfin client via the Jellyfin
# Sessions remote-control API.
#
# Usage:
# play_jellyfin.sh "Castle" appletv
# play_jellyfin.sh "The Rookie" chromecast
# play_jellyfin.sh "John Wick movie" appletv
# play_jellyfin.sh --sessions (list live sessions, for config)
#
# ── Design properties ────────────────────────────────────────────────────────
# - fail-loud: each fatal stop sends a distinct alert (swap in your own
# alerting in fail_loud below)
# - distinct stops: server unreachable vs no match vs no session vs command
# rejected vs never started vs started-then-died
# - honest-synchronous: Home Assistant waits for this script, so the voice
# agent only claims playback after it is VERIFIED; failures surface as its
# spoken reply
# - two-phase verification with a sustain re-check (a session that appears
# and dies in a second is a real failure mode and must NOT verify)
# - global time budget keeps the run inside HA's 60s shell_command kill window
# - search ladder over query variants (STT says "and", libraries use "&")
# - last stdout line is machine-readable: "PLAYING: ..." or "FAILED: ..."
#
# ── What is structurally different from Plex, and matters ────────────────────
# Plex Companion registers a discoverable client on :32500 that answers even
# when the app is backgrounded. Jellyfin has no equivalent: a session exists
# ONLY while the client app is running and connected. There is no port to poll.
#
# Consequences:
# 1. Readiness is "does a matching session appear in /Sessions", not "is a
# port open" — and unlike a port, a Jellyfin session is a truthful signal.
# 2. If the app is not running we must launch it first; there is no fallback,
# so the launch config below is mandatory.
# 3. Cold start is slower, because the app must fully connect to the server
# before its session registers.
#
# Ticks: Jellyfin positions are 100-nanosecond ticks. 1 ms = 10,000 ticks.
set -Eeuo pipefail
SECRETS="$HOME/.config/secrets.env" # holds JELLYFINAPI and HA_TOKEN
LOG="$HOME/logs/play-jellyfin.log"
BUDGET=50 # seconds; HA kills shell_command at 60s, HA-side pre-steps eat ~6s.
# A cold tvOS start needs ~30s of launch-wait AND up to
# VERIFY_WINDOW of verify; 45 could not hold both.
mkdir -p "$HOME/logs"
log() {
echo "[$(date '+%H:%M:%S')] $*" | tee -a "$LOG"
}
# Fail loud: log, alert, terse machine-readable last line, non-zero exit.
fail_loud() {
local reason="$1"
local spoken="${2:-Could not play ${TITLE:-that} from Jellyfin.}"
log "ERROR: $reason"
# Substitute your own alerting (ntfy, Telegram, email...):
"$HOME/scripts/alert-send.sh" "play_jellyfin: ${reason} — title='${TITLE:-?}' target=${TARGET:-?}" \
>/dev/null 2>>"$LOG" || log "WARN: alert also failed"
echo "FAILED: $spoken"
exit 1
}
if [[ ! -f "$SECRETS" ]]; then
log "ERROR: Secrets file not found: $SECRETS"
exit 1
fi
source "$SECRETS"
TITLE="${1:-}"
TARGET="${2:-}"
# Per-site config: server URL, library user, HA URL, and the target map.
# The ONLY file that should differ between installations.
TARGETS_CONF="$HOME/scripts/jellyfin-targets.conf"
[[ -f "$TARGETS_CONF" ]] || { echo "FAILED: jellyfin-targets.conf missing."; exit 1; }
source "$TARGETS_CONF"
: "${JELLYFINAPI:?JELLYFINAPI is missing from secrets.env}"
# Discovery helper: prints every live session so the target map can be filled
# in with the exact Client / DeviceName strings. Open Moonfin on the device
# first, then: play_jellyfin.sh --sessions
if [[ "$TITLE" == "--sessions" ]]; then
curl -sS -f -H "Authorization: MediaBrowser Token=\"${JELLYFINAPI}\", Client=\"MorisonicsVoice\", Device=\"voice-host\", DeviceId=\"morisonics-voice-1\", Version=\"1.0\"" -H "Accept: application/json" \
--max-time 15 "${JELLYFIN_URL}/Sessions" \
| python3 -c '
import json, sys
rows = json.load(sys.stdin)
if not rows:
print("(no sessions — open the app on the device first)"); sys.exit(0)
print("%-28s %-22s %-6s %s" % ("CLIENT", "DEVICE", "REMOTE", "SESSION ID"))
for s in rows:
print("%-28s %-22s %-6s %s" % (
(s.get("Client") or "")[:28],
(s.get("DeviceName") or "")[:22],
"yes" if s.get("SupportsRemoteControl") else "no",
s.get("Id", "")))
'
exit 0
fi
[[ -z "$TITLE" || -z "$TARGET" ]] && {
log "Usage: play_jellyfin.sh <title> <appletv|chromecast>"
log " play_jellyfin.sh --sessions (list live sessions)"
fail_loud "called without title/target (usage error)"
}
: "${HA_TOKEN:?HA_TOKEN is missing from secrets.env}"
LAUNCHED=0
jellyfin_target "$TARGET" || fail_loud "unknown target: $TARGET (known: ${JELLYFIN_TARGETS})"
jf() {
# jf <path-with-query> [curl args...]
local path="$1"; shift
curl -sS -f -H "Authorization: MediaBrowser Token=\"${JELLYFINAPI}\", Client=\"MorisonicsVoice\", Device=\"voice-host\", DeviceId=\"morisonics-voice-1\", Version=\"1.0\"" -H "Accept: application/json" \
--max-time 15 "${JELLYFIN_URL}${path}" "$@" 2>>"$LOG"
}
urlencode() {
python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$1"
}
# Set whenever any Jellyfin endpoint answers — distinguishes "no match" from
# "server unreachable".
JF_HTTP_OK=0
# ── Resolve the library user ─────────────────────────────────────────────────
USERS_JSON="$(jf "/Users")" || fail_loud "Jellyfin server unreachable (/Users failed)" \
"The Jellyfin server is not responding."
JF_HTTP_OK=1
USER_ID=$(DATA="$USERS_JSON" NAME="$JELLYFIN_USERNAME" python3 -c '
import json, os, sys
users = json.loads(os.environ["DATA"])
want = os.environ["NAME"].strip().lower()
for u in users:
if (u.get("Name") or "").strip().lower() == want:
print(u.get("Id", "")); sys.exit(0)
# Fall back to the first administrator, then simply the first user.
for u in users:
if (u.get("Policy") or {}).get("IsAdministrator"):
print(u.get("Id", "")); sys.exit(0)
print(users[0].get("Id", "") if users else "")
')
[[ -n "$USER_ID" ]] || fail_loud "could not resolve a Jellyfin user id (looked for '${JELLYFIN_USERNAME}')"
log "User: ${JELLYFIN_USERNAME} (${USER_ID})"
# ── Intent hints ─────────────────────────────────────────────────────────────
LOWER_TITLE="$(echo "$TITLE" | tr '[:upper:]' '[:lower:]')"
PREFER_MOVIE=0
if [[ "$LOWER_TITLE" =~ (^|[[:space:]])(movie|film)([[:space:]]|$) ]]; then
PREFER_MOVIE=1
fi
SEARCH_TITLE=$(python3 -c '
import re, sys
s = sys.argv[1]
s = re.sub(r"\b(tv show|movie|film|series|show|tv)\b", "", s, flags=re.I)
s = " ".join(s.split())
print(s if s else sys.argv[1])
' "$TITLE")
log "Looking for '$TITLE' → search='$SEARCH_TITLE' → $TARGET"
mapfile -t QUERY_VARIANTS < <(python3 -c '
import re, sys
s = sys.argv[1]
out = []
def add(v):
v = " ".join(v.split())
if v and v.lower() not in [o.lower() for o in out]:
out.append(v)
add(s)
add(re.sub(r"\band\b", "&", s, flags=re.I))
add(s.replace("&", " and "))
add(re.sub(r"[^\w\s&]", " ", s))
print("\n".join(out))
' "$SEARCH_TITLE")
ITEM=""
# ── Pickers ──────────────────────────────────────────────────────────────────
# One shared normaliser: lowercase, & ↔ and, leading "the" dropped,
# whitespace collapsed.
PICKER_PRELUDE='
import json, os, re, sys
def norm(s):
s = (s or "").lower().strip()
s = s.replace("&", " and ")
s = re.sub(r"^the\s+", "", s)
return " ".join(s.split())
def rank(items, query, key):
exact = [i for i in items if norm(key(i)) == query]
contains = [i for i in items if query in norm(key(i)) or norm(key(i)) in query]
return exact or contains or items
'
choose_by_type() {
# choose_by_type <json> <query> <Type>
DATA="$1" QUERY="$2" WANT="$3" python3 -c "${PICKER_PRELUDE}"'
try:
data = json.loads(os.environ["DATA"])
except Exception:
sys.exit(0)
query = norm(os.environ["QUERY"])
want = os.environ["WANT"]
items = [i for i in (data.get("Items") or []) if i.get("Type") == want]
if not items:
sys.exit(0)
print(json.dumps(rank(items, query, lambda i: i.get("Name"))[0]))
'
}
choose_hint() {
# choose_hint <json from /Search/Hints> <query>
DATA="$1" QUERY="$2" python3 -c "${PICKER_PRELUDE}"'
try:
data = json.loads(os.environ["DATA"])
except Exception:
sys.exit(0)
query = norm(os.environ["QUERY"])
hints = data.get("SearchHints") or []
if not hints:
sys.exit(0)
movies = [h for h in hints if h.get("Type") == "Movie"]
series = [h for h in hints if h.get("Type") == "Series"]
episodes = [h for h in hints if h.get("Type") == "Episode"]
movie_exact = [h for h in movies if norm(h.get("Name")) == query]
episode_exact = [h for h in episodes if norm(h.get("Name")) == query]
series_exact = [h for h in series if norm(h.get("Name")) == query]
ep_show_exact = [h for h in episodes if norm(h.get("SeriesName")) == query]
movie_part = [h for h in movies if query in norm(h.get("Name")) or norm(h.get("Name")) in query]
series_part = [h for h in series if query in norm(h.get("Name")) or norm(h.get("Name")) in query]
episode_part = [h for h in episodes if query in norm(h.get("Name")) or norm(h.get("Name")) in query]
playable = [h for h in hints if h.get("Type") in ("Movie", "Series", "Episode")]
chosen = (movie_exact or series_exact or ep_show_exact or episode_exact
or movie_part or series_part or episode_part or playable)
if not chosen:
sys.exit(0)
h = chosen[0]
# Normalise a hint into an item-shaped dict.
print(json.dumps({
"Id": h.get("ItemId") or h.get("Id"),
"Name": h.get("Name"),
"Type": h.get("Type"),
"SeriesName": h.get("SeriesName"),
"IndexNumber": h.get("IndexNumber"),
"ParentIndexNumber": h.get("ParentIndexNumber"),
"UserData": {},
}))
'
}
# Episode selection: resume first (most recently played), then first unwatched
# in season/episode order, then the beginning.
choose_episode() {
python3 -c '
import json, sys
try:
data = json.load(sys.stdin)
except Exception as e:
print(f"EPISODE_PICKER_ERROR: {e}", file=sys.stderr); sys.exit(1)
eps = [i for i in (data.get("Items") or []) if i.get("Type") == "Episode"]
if not eps:
print("EPISODE_PICKER_ERROR: no episodes", file=sys.stderr); sys.exit(1)
def iv(v, d=0):
try: return int(v)
except Exception: return d
def ud(e, k, d=0):
return (e.get("UserData") or {}).get(k, d)
def order(e):
return (iv(e.get("ParentIndexNumber"), 9999),
iv(e.get("IndexNumber"), 9999),
str(e.get("Id", "")))
resume = [e for e in eps if iv(ud(e, "PlaybackPositionTicks")) > 0 and not ud(e, "Played", False)]
if resume:
resume.sort(key=lambda e: (str(ud(e, "LastPlayedDate", "")), iv(ud(e, "PlaybackPositionTicks"))),
reverse=True)
print(json.dumps(resume[0])); sys.exit(0)
unwatched = [e for e in eps if not ud(e, "Played", False)]
if unwatched:
unwatched.sort(key=order)
print(json.dumps(unwatched[0])); sys.exit(0)
eps.sort(key=order)
print(json.dumps(eps[0]))
'
}
# ── Search rungs ─────────────────────────────────────────────────────────────
try_resume() {
# Jellyfin's equivalent of on-deck. Non-fatal: this endpoint has moved
# between Jellyfin versions and must never be a hard stop.
local resume
resume=$(jf "/Users/${USER_ID}/Items/Resume?Limit=30&Fields=UserData&MediaTypes=Video") || {
log "WARN: Resume fetch failed — continuing with search"
return 1
}
JF_HTTP_OK=1
ITEM=$(DATA="$resume" QUERY="$SEARCH_TITLE" python3 -c "${PICKER_PRELUDE}"'
try:
data = json.loads(os.environ["DATA"])
except Exception:
sys.exit(0)
query = norm(os.environ["QUERY"])
for i in (data.get("Items") or []):
name = norm(i.get("Name"))
show = norm(i.get("SeriesName") or i.get("Name"))
if query in (name, show) or query in show or show in query or query in name or name in query:
print(json.dumps(i)); sys.exit(0)
' 2>/dev/null || true)
[[ -n "$ITEM" ]]
}
resolve_series_to_episode() {
# $1 = series item JSON. Prefers NextUp, falls back to the full episode list.
local series_item series_id series_name nextup episodes
series_item="$1"
series_id=$(DATA="$series_item" python3 -c 'import json,os; print(json.loads(os.environ["DATA"]).get("Id",""))')
series_name=$(DATA="$series_item" python3 -c 'import json,os; print(json.loads(os.environ["DATA"]).get("Name",""))')
[[ -n "$series_id" ]] || return 1
log "Matched series: $series_name ($series_id)"
# NextUp is Jellyfin's own "what should play next" — cheaper and smarter
# than reimplementing it, but it returns nothing for a never-started series.
nextup=$(jf "/Shows/NextUp?userId=${USER_ID}&seriesId=${series_id}&Fields=UserData&Limit=1") || nextup=""
if [[ -n "$nextup" ]]; then
ITEM=$(DATA="$nextup" python3 -c '
import json, os, sys
items = (json.loads(os.environ["DATA"]).get("Items") or [])
if items:
print(json.dumps(items[0]))
' 2>/dev/null || true)
if [[ -n "$ITEM" ]]; then
log "NextUp supplied the episode"
return 0
fi
fi
episodes=$(jf "/Shows/${series_id}/Episodes?userId=${USER_ID}&Fields=UserData") || {
log "WARN: could not fetch episodes for: $series_name"
return 1
}
ITEM="$(printf "%s" "$episodes" | choose_episode 2>>"$LOG" || true)"
[[ -n "$ITEM" ]]
}
try_items_search() {
# try_items_search <Series|Movie>
local want="$1" search picked
log "Searching ${want}..."
search=$(jf "/Items?userId=${USER_ID}&searchTerm=${ENCODED_TITLE}&IncludeItemTypes=${want}&Recursive=true&Limit=15&Fields=UserData") || {
log "WARN: ${want} search failed"
return 1
}
JF_HTTP_OK=1
picked="$(choose_by_type "$search" "$SEARCH_TITLE" "$want" 2>/dev/null || true)"
[[ -n "$picked" ]] || return 1
if [[ "$want" == "Series" ]]; then
resolve_series_to_episode "$picked"
else
ITEM="$picked"
fi
[[ -n "$ITEM" ]]
}
try_hint_search() {
# /Search/Hints is the fuzzier rung — and the one that matches episode titles.
local search hint hint_type
log "Trying Jellyfin search hints (matches episode titles)..."
search=$(jf "/Search/Hints?searchTerm=${ENCODED_TITLE}&userId=${USER_ID}&includeItemTypes=Movie,Series,Episode&limit=15") || {
log "WARN: hint search failed"
return 1
}
JF_HTTP_OK=1
hint="$(choose_hint "$search" "$SEARCH_TITLE" 2>/dev/null || true)"
[[ -n "$hint" ]] || return 1
hint_type=$(DATA="$hint" python3 -c 'import json,os; print(json.loads(os.environ["DATA"]).get("Type",""))')
if [[ "$hint_type" == "Series" ]]; then
resolve_series_to_episode "$hint"
else
ITEM="$hint"
fi
[[ -n "$ITEM" ]]
}
# ── 1. Resume, unless a movie was explicitly asked for ───────────────────────
if [[ "$PREFER_MOVIE" -eq 0 ]]; then
try_resume || true
fi
# ── 2. Search ladder over query variants ─────────────────────────────────────
# TV-first by default: voice requests like "The Rookie" usually mean the series.
if [[ -z "$ITEM" ]]; then
for VARIANT in "${QUERY_VARIANTS[@]}"; do
SEARCH_TITLE="$VARIANT"
ENCODED_TITLE="$(urlencode "$VARIANT")"
log "Search variant: '$VARIANT'"
if [[ "$PREFER_MOVIE" -eq 1 ]]; then
try_items_search Movie || try_items_search Series || try_hint_search || true
else
try_items_search Series || try_items_search Movie || try_hint_search || true
fi
[[ -n "$ITEM" ]] && break
done
fi
if [[ -z "$ITEM" ]]; then
if [[ "$JF_HTTP_OK" -eq 0 ]]; then
fail_loud "Jellyfin server unreachable (resume and all search endpoints failed)" \
"The Jellyfin server is not responding."
fi
fail_loud "no Jellyfin match for '$TITLE' (tried ${#QUERY_VARIANTS[@]} query variants incl. hints)" \
"Could not find ${TITLE} in the Jellyfin library."
fi
# ── 3. Extract metadata ──────────────────────────────────────────────────────
ITEM_ID=$(DATA="$ITEM" python3 -c 'import json,os; print(json.loads(os.environ["DATA"]).get("Id",""))')
ITEM_TYPE=$(DATA="$ITEM" python3 -c 'import json,os; print(json.loads(os.environ["DATA"]).get("Type",""))')
EP_TITLE=$(DATA="$ITEM" python3 -c 'import json,os; print(json.loads(os.environ["DATA"]).get("Name",""))')
SHOW_TITLE=$(DATA="$ITEM" python3 -c 'import json,os; d=json.loads(os.environ["DATA"]); print(d.get("SeriesName") or d.get("Name",""))')
SEASON=$(DATA="$ITEM" python3 -c 'import json,os; print(json.loads(os.environ["DATA"]).get("ParentIndexNumber") or "")')
EPISODE=$(DATA="$ITEM" python3 -c 'import json,os; print(json.loads(os.environ["DATA"]).get("IndexNumber") or "")')
START_TICKS=$(DATA="$ITEM" python3 -c '
import json, os
d = json.loads(os.environ["DATA"])
ud = d.get("UserData") or {}
print(int(ud.get("PlaybackPositionTicks") or 0))
')
[[ -n "$ITEM_ID" ]] || fail_loud "matched an item with no Id (unexpected API shape)"
if [[ "$ITEM_TYPE" == "Episode" ]]; then
log "Found episode: $SHOW_TITLE S${SEASON}E${EPISODE} / $EP_TITLE (ticks=${START_TICKS})"
else
log "Found ${ITEM_TYPE}: $EP_TITLE (ticks=${START_TICKS})"
fi
# ── 4. Find the target session, launching the app if needed ──────────────────
# A session in /Sessions IS the readiness signal, and it is a truthful one:
# if it is there, the app is connected.
find_session_id() {
local sessions
sessions=$(jf "/Sessions") || return 1
JF_HTTP_OK=1
DATA="$sessions" WANT_CLIENT="$SESSION_CLIENT" WANT_DEVICE="$SESSION_DEVICE" python3 -c '
import json, os, sys
try:
sessions = json.loads(os.environ["DATA"])
except Exception:
sys.exit(1)
wc = os.environ["WANT_CLIENT"].strip().lower()
wd = os.environ["WANT_DEVICE"].strip().lower()
cands = []
for s in sessions:
client = (s.get("Client") or "").strip().lower()
device = (s.get("DeviceName") or "").strip().lower()
if not s.get("SupportsRemoteControl"):
continue
if wc and wc not in client:
continue
if wd and wd not in device:
continue
cands.append((client, device, s.get("Id", "")))
if not cands:
sys.exit(1)
# Prefer an exact client-name match. "Moonfin" is a SUBSTRING of "Moonfin for
# tvOS", so a loose match would silently play to the wrong room. Wrong-room
# actuation is worse than failing: if the candidates cannot be narrowed to
# exactly one, refuse and report them.
exact = [c for c in cands if c[0] == wc] if wc else []
if wd:
exact_dev = [c for c in (exact or cands) if c[1] == wd]
if len(exact_dev) == 1:
print(exact_dev[0][2]); sys.exit(0)
pool = exact or cands
if len(pool) == 1:
print(pool[0][2]); sys.exit(0)
sys.stderr.write("AMBIGUOUS: %d sessions match client=%r device=%r -> %s\n" % (
len(pool), wc, wd, "; ".join("%s / %s" % (c[0], c[1]) for c in pool)))
sys.exit(2)
'
}
# A matching session that refuses remote control is its own failure state and
# must be NAMED, not hidden inside "no session". Observed: after an app
# update, tvOS Moonfin re-registered with SupportsRemoteControl=false until
# the Apple TV itself was restarted — the generic "did not come online" error
# turned a one-line fix into a forty-minute mystery.
find_uncontrollable() {
local sessions
sessions=$(jf "/Sessions") || return 1
DATA="$sessions" WANT_CLIENT="$SESSION_CLIENT" WANT_DEVICE="$SESSION_DEVICE" python3 -c '
import json, os, sys
try:
sessions = json.loads(os.environ["DATA"])
except Exception:
sys.exit(1)
wc = os.environ["WANT_CLIENT"].strip().lower()
wd = os.environ["WANT_DEVICE"].strip().lower()
for s in sessions:
client = (s.get("Client") or "").strip().lower()
device = (s.get("DeviceName") or "").strip().lower()
if wc and wc not in client:
continue
if wd and wd not in device:
continue
if not s.get("SupportsRemoteControl"):
print("%s / %s" % (s.get("Client"), s.get("DeviceName")))
sys.exit(0)
sys.exit(1)
'
}
ha_service() {
# ha_service <domain/service> <json body>
curl -sS -f -X POST "${HA_URL}/api/services/$1" \
-H "Authorization: Bearer ${HA_TOKEN}" \
-H "Content-Type: application/json" \
-d "$2" --max-time 10 >/dev/null 2>>"$LOG"
}
# Prints the entity's source_list — used to explain a failed select_source
# rather than leaving the operator guessing at the app's exact name.
report_sources() {
curl -sS -f "${HA_URL}/api/states/${LAUNCH_ENTITY}" \
-H "Authorization: Bearer ${HA_TOKEN}" --max-time 10 2>>"$LOG" \
| python3 -c '
import json, sys
try:
a = json.load(sys.stdin).get("attributes", {})
except Exception:
sys.exit(0)
srcs = a.get("source_list") or []
print(" available sources: " + (", ".join(map(str, srcs)) if srcs else "(none reported)"))
' 2>>"$LOG" || true
}
launch_app() {
case "$LAUNCH_METHOD" in
androidtv_app)
[[ -n "$LAUNCH_APP_ID" ]] || return 1
ha_service "media_player/play_media" \
"{\"entity_id\": \"${LAUNCH_ENTITY}\", \"media_content_type\": \"app\", \"media_content_id\": \"${LAUNCH_APP_ID}\"}"
;;
appletv_source)
[[ -n "$LAUNCH_SOURCE" ]] || return 1
# The Apple TV sleeps; select_source on a sleeping device does
# nothing. Wake it and then VERIFY it actually woke (entity state
# leaves off/standby) before launching.
ha_service "media_player/turn_on" "{\"entity_id\": \"${LAUNCH_ENTITY}\"}" || true
local _waited=0 _atv_state=""
while (( _waited < 12 )); do
_atv_state=$(curl -sS -f "${HA_URL}/api/states/${LAUNCH_ENTITY}" \
-H "Authorization: Bearer ${HA_TOKEN}" --max-time 5 2>>"$LOG" \
| python3 -c 'import json,sys; print(json.load(sys.stdin).get("state",""))' 2>>"$LOG" || echo "")
if [[ -n "$_atv_state" && "$_atv_state" != "off" && "$_atv_state" != "standby" && "$_atv_state" != "unavailable" ]]; then
break
fi
sleep 2
_waited=$(( _waited + 2 ))
done
if [[ -z "$_atv_state" || "$_atv_state" == "off" || "$_atv_state" == "standby" || "$_atv_state" == "unavailable" ]]; then
log "WARN: ${LAUNCH_ENTITY} still '${_atv_state:-unreachable}' after wake attempts — launching anyway"
else
log "${LAUNCH_ENTITY} awake (state=${_atv_state}) after ${_waited}s"
fi
ha_service "media_player/select_source" \
"{\"entity_id\": \"${LAUNCH_ENTITY}\", \"source\": \"${LAUNCH_SOURCE}\"}" || {
log "WARN: select_source '${LAUNCH_SOURCE}' failed"
report_sources | tee -a "$LOG"
return 1
}
;;
*)
return 1
;;
esac
}
# Is a launch path actually configured for this target?
LAUNCH_CONFIGURED=0
if [[ "$LAUNCH_METHOD" == "androidtv_app" && -n "$LAUNCH_APP_ID" ]]; then
LAUNCH_CONFIGURED=1
fi
if [[ "$LAUNCH_METHOD" == "appletv_source" && -n "$LAUNCH_SOURCE" ]]; then
LAUNCH_CONFIGURED=1
fi
# Wake the display FIRST, before any app-launch attempt. An app-launch command
# sent to a sleeping device is silently dropped — the TV comes up on the
# launcher while a stale background session accepts playback invisibly.
if [[ -n "${LAUNCH_ENTITY:-}" ]]; then
ha_service "media_player/turn_on" "{\"entity_id\": \"${LAUNCH_ENTITY}\"}" \
&& log "Sent turn_on to ${LAUNCH_ENTITY}" \
|| log "WARN: turn_on for ${LAUNCH_ENTITY} failed (continuing)"
sleep 2
fi
SESSION_ID="$(find_session_id || true)"
if [[ -z "$SESSION_ID" ]]; then
if [[ "$LAUNCH_CONFIGURED" -eq 1 ]]; then
log "No ${SESSION_CLIENT} session — launching Moonfin on ${LAUNCH_ENTITY} (${LAUNCH_METHOD})..."
LAUNCHED=1
launch_app || log "WARN: app launch via HA failed — polling anyway"
LAST_LAUNCH=$SECONDS
# Launch-wait must match the TARGET's measured session-registration
# time, not a global constant: a cold tvOS Moonfin can register its
# server session AFTER a too-eager cutoff — walking away from a
# launch that had actually worked.
while (( SECONDS < BUDGET - 12 )); do
SESSION_ID="$(find_session_id || true)"
[[ -n "$SESSION_ID" ]] && break
# Re-sends are ANDROID-ONLY. On tvOS every select_source RESTARTS
# Moonfin and kills the server handshake it was mid-way through.
# Launch once, then wait patiently; check for late success instead.
# 15s between cycles: a cold Moonfin needs ~10-15s to boot AND
# finish its handshake — an 8s cycle interrupts registration
# mid-flight, repeatedly.
if [[ "$LAUNCH_METHOD" == "androidtv_app" ]] && (( SECONDS - LAST_LAUNCH >= 15 )); then
# A repeated launch intent at an already-running Moonfin is
# IGNORED. If a remote entity is configured, send HOME first —
# a real background→foreground cycle fires onAppResumed, which
# is what makes Moonfin ≥2.5.0 reconnect its socket and
# re-post capabilities.
if [[ -n "${LAUNCH_REMOTE:-}" ]]; then
log "Cycling Moonfin: HOME, then relaunch..."
ha_service "remote/send_command" \
"{\"entity_id\": \"${LAUNCH_REMOTE}\", \"command\": \"HOME\"}" \
|| log "WARN: HOME via ${LAUNCH_REMOTE} failed"
sleep 2
else
log "Re-sending app launch..."
fi
launch_app || true
LAST_LAUNCH=$SECONDS
fi
sleep 2
done
else
log "No session and no launch path configured for ${TARGET}"
fi
fi
if [[ -z "$SESSION_ID" ]]; then
if [[ "$LAUNCH_CONFIGURED" -eq 0 ]]; then
fail_loud "no ${SESSION_CLIENT:-configured} session and no launch path for ${TARGET}" \
"Please open Moonfin on the ${TARGET} first."
fi
UNCTRL="$(find_uncontrollable || true)"
if [[ -n "$UNCTRL" ]]; then
fail_loud "session for ${TARGET} exists but SupportsRemoteControl=false (${UNCTRL}) — restart the device (proven remedy)" \
"Moonfin is open on the ${TARGET} but not accepting control. Restarting that TV device fixes this."
fi
# Moonfin that fails its FIRST connect (server still warming after a
# restart) never retries on its own — but a relaunch against a warm server
# registers in ~6s. So on the way out, fire one last HOME+relaunch so the
# app boots fresh against the now-warm server, and tell the user the
# truth: the retry will work.
if [[ "$LAUNCHED" -eq 1 && "$LAUNCH_METHOD" == "androidtv_app" && -n "${LAUNCH_REMOTE:-}" ]]; then
log "Parting shot: HOME + relaunch so the NEXT attempt finds a live session"
ha_service "remote/send_command" \
"{\"entity_id\": \"${LAUNCH_REMOTE}\", \"command\": \"HOME\"}" || true
sleep 2
launch_app || true
fail_loud "no controllable session appeared on ${TARGET} within budget (parting relaunch fired)" \
"The ${TARGET} app is still starting up. Ask me again in half a minute."
fi
fail_loud "no controllable session appeared on ${TARGET} within budget" \
"The ${TARGET} did not come online, so ${TITLE} is not playing."
fi
log "Target session: ${SESSION_ID} (t=${SECONDS}s)"
# A live session does NOT mean the display is on, and it does NOT mean the app
# is on screen — Moonfin keeps its session while backgrounded, so playback
# sent to it progresses invisibly and then stalls. ALWAYS bring the app to the
# foreground; a relaunch can register a NEW session, so re-resolve afterwards.
if [[ "$LAUNCH_CONFIGURED" -eq 1 ]]; then
log "Foregrounding Moonfin on ${LAUNCH_ENTITY} (${LAUNCH_METHOD})..."
launch_app || log "WARN: app foreground failed (continuing)"
sleep 4
NEW_SID="$(find_session_id || true)"
if [[ -n "$NEW_SID" && "$NEW_SID" != "$SESSION_ID" ]]; then
log "Session changed after foregrounding: ${SESSION_ID} -> ${NEW_SID}"
SESSION_ID="$NEW_SID"
fi
fi
# ── 5. Play, verify, sustain-check, single retry ─────────────────────────────
# Exit codes:
# 0 verified 4 command rejected 5 never started 6 started then died
# 7 loaded but never played (backgrounded / display off)
send_play() {
curl -sS -f -X POST \
-H "Authorization: MediaBrowser Token=\"${JELLYFINAPI}\", Client=\"MorisonicsVoice\", Device=\"voice-host\", DeviceId=\"morisonics-voice-1\", Version=\"1.0\"" \
-H "Content-Length: 0" \
--max-time 15 \
"${JELLYFIN_URL}/Sessions/${SESSION_ID}/Playing?playCommand=PlayNow&itemIds=${ITEM_ID}&startPositionTicks=${START_TICKS}" \
>/dev/null 2>>"$LOG"
}
# Prints "<state> <ticks>" when the target session is playing OUR item.
observe() {
local sessions
sessions=$(jf "/Sessions") || return 1
DATA="$sessions" SID="$SESSION_ID" IID="$ITEM_ID" python3 -c '
import json, os, sys
try:
sessions = json.loads(os.environ["DATA"])
except Exception:
sys.exit(1)
sid, iid = os.environ["SID"], os.environ["IID"]
for s in sessions:
if s.get("Id") != sid:
continue
npi = s.get("NowPlayingItem") or {}
if npi.get("Id") != iid:
sys.exit(1)
ps = s.get("PlayState") or {}
state = "paused" if ps.get("IsPaused") else "playing"
print("%s %s" % (state, ps.get("PositionTicks") or 0))
sys.exit(0)
sys.exit(1)
'
}
play_and_verify() {
local attempt="$1" deadline first second
log "Sending play command (attempt ${attempt}, t=${SECONDS}s)..."
# RESTART SAFETY: on a retry, look for an existing session first. A blind
# re-send restarts the player mid-startup — exactly the "started then
# stopped" failure retries used to cause.
if [[ "$attempt" != "1" ]] && observe >/dev/null 2>&1; then
log "note attempt=${attempt}: already playing — skipping resend, verifying instead"
else
send_play || { log "PLAY_ERROR attempt=${attempt}: Playing command rejected"; return 4; }
fi
# ── Verification: poll for TWO CONSECUTIVE "playing" samples ─────────────
#
# Measured on real clients. A single sample cannot tell these apart,
# because at any given instant they look identical:
#
# Android TV, app BACKGROUNDED (really not visible):
# playing 4914130000 -> paused 4933280000 (advances, then pauses)
# Apple TV, genuinely playing on screen:
# paused 2753000000 -> paused 2753000000 (for the first ~15s!)
# ... then later: playing -> playing
#
# tvOS Moonfin reports IsPaused=true with a frozen position for roughly
# the first 15 seconds of real playback — its PlayState lags reality. So
# a snapshot at a fixed moment produced a false NEGATIVE on the Apple TV
# and a false POSITIVE on the Chromecast, in successive versions of this
# check.
#
# What separates them is behaviour over time: a backgrounded client
# DEGRADES (playing -> paused), a real one HOLDS (playing -> playing).
# Position is deliberately NOT part of the test: tvOS was observed ticking
# BACKWARDS while genuinely playing (10000000 -> 9000000).
local cur state ticks consec=0 saw_item=0 misses=0
deadline=$(( SECONDS + (attempt == 1 ? VERIFY_WINDOW : 12) ))
# Never let the verify window sail past the global budget: HA kills the
# shell at 60s, and a truncated run reports NOTHING — worse than an
# honest shorter verify.
(( deadline > BUDGET )) && deadline=$BUDGET
while (( SECONDS < deadline )); do
cur="$(observe 2>/dev/null || true)"
if [[ -z "$cur" ]]; then
# Tolerate a single blank read (transient API hiccup); two in a
# row after the item was seen means the session really dropped it.
if (( saw_item )); then
misses=$(( misses + 1 ))
if (( misses >= 2 )); then
log "PLAY_ERROR attempt=${attempt}: item was loaded but the session dropped it"
return 6
fi
fi
sleep 2
continue
fi
misses=0
saw_item=1
read -r state ticks <<< "$cur"
if [[ "$state" == "playing" ]]; then
consec=$(( consec + 1 ))
if (( consec >= 2 )); then
log "VERIFIED attempt=${attempt}: two consecutive playing samples (${cur}, t=${SECONDS}s)"
return 0
fi
else
consec=0
fi
sleep 2
done
if (( saw_item )); then
log "PLAY_ERROR attempt=${attempt}: item loaded but never reported playing twice in ${VERIFY_WINDOW}s"
return 7
fi
log "PLAY_ERROR attempt=${attempt}: command sent but the item never appeared in the session"
return 5
}
RC=0
play_and_verify 1 || RC=$?
# A retry costs ~14s. Gate on the real remaining budget rather than a fixed
# constant, so widening a verify window for a slow client does not silently
# disable the retry.
if [[ "$RC" -ne 0 ]] && (( SECONDS + 14 < BUDGET )); then
log "WARN: attempt 1 failed (code $RC) — retrying once..."
sleep 2
RC=0
play_and_verify 2 || RC=$?
elif [[ "$RC" -ne 0 ]]; then
log "WARN: attempt 1 failed (code $RC) — no budget left for a retry (t=${SECONDS}s)"
fi
# Late-success grace watch. A launch that was merely SLOW can start playing
# moments after the verify window closes. Observing costs no resend, so spend
# every remaining second watching before declaring failure.
if [[ "$RC" -ne 0 ]]; then
GRACE_CONSEC=0
while (( SECONDS < BUDGET - 1 )); do
CUR="$(observe 2>/dev/null || true)"
if [[ "${CUR%% *}" == "playing" ]]; then
GRACE_CONSEC=$(( GRACE_CONSEC + 1 ))
if (( GRACE_CONSEC >= 2 )); then
log "VERIFIED late (grace watch): playback started after the verify window (${CUR}, t=${SECONDS}s)"
RC=0
break
fi
else
GRACE_CONSEC=0
fi
sleep 2
done
fi
if [[ "$RC" -ne 0 ]]; then
case "$RC" in
4) fail_loud "session ${SESSION_ID} on ${TARGET} rejected the Playing command" \
"The ${TARGET} did not respond, so ${TITLE} is not playing." ;;
5) fail_loud "play command sent but playback never started on ${TARGET}" \
"${TITLE} did not start playing on the ${TARGET}." ;;
6) fail_loud "playback started on ${TARGET} but died within seconds" \
"${TITLE} started and then immediately stopped on the ${TARGET}." ;;
7) fail_loud "item loaded on ${TARGET} but never reported playing twice (backgrounded / display off?)" \
"${TITLE} is loaded on the ${TARGET} but not playing. The screen may be off." ;;
*) fail_loud "playback failed with unexpected code $RC" ;;
esac
fi
log "Playback verified on $TARGET: $SHOW_TITLE / $EP_TITLE (t=${SECONDS}s)"
if [[ "$ITEM_TYPE" == "Episode" ]]; then
echo "PLAYING: $SHOW_TITLE S${SEASON}E${EPISODE} ($EP_TITLE) on the $TARGET"
else
echo "PLAYING: $EP_TITLE on the $TARGET"
fi
สองชิ้น: shell_command ที่รันสคริปต์ และเอนทิตีสคริปต์ที่เปิดให้ Assist ใช้ ซึ่งคำอธิบายฟิลด์มีกฎการจัดเส้นทาง ในการทดสอบของเรา กฎว่าจะกรอกพารามิเตอร์อย่างไรได้ผลดีที่สุดเมื่ออยู่ในคำอธิบายของพารามิเตอร์นั้นเอง — โมเดลอ่านมันในจังหวะที่ต้องใช้พอดี
# configuration.yaml — the bridge between the voice agent and the script.
# NOTE: adding a shell_command needs `shell_command.reload` (or a restart)
# before the script that calls it will work.
shell_command:
play_jellyfin: '/config/scripts/play_jellyfin.sh "{{ title }}" "{{ target }}"'
jellyfin_transport: '/config/scripts/jellyfin-transport.sh "{{ command }}" "{{ target }}"'
# The data key is `command`, not `action` — `action` collides with HA's own
# step keyword and the validator flags it.
# scripts.yaml — the tool the voice agent actually sees. The field
# descriptions ARE the interface: they tell the model how to fill the
# parameters, and in our measurements that is where such rules belong.
script:
play_jellyfin:
alias: Play Jellyfin
description: >-
Play a film or TV series from the Jellyfin library on a TV. Waits for
verified playback and returns the result sentence - speak it as-is.
fields:
title:
description: >-
The film or show title, in Latin letters. If the person said a
title in another script, spell it the way the library would
(e.g. a romanised title), because the library is stored in Latin
spelling.
required: true
target:
description: >-
Which TV to play on. appletv = the living room TV. chromecast =
the bedroom TV. If the person named a device or a room, use that.
If they named NEITHER, use the room they are speaking from: a
request from the living room is appletv, a request from the
bedroom is chromecast. Never default to one room for a request
made in the other.
required: true
sequence:
- service: shell_command.play_jellyfin
data:
title: "{{ title }}"
target: "{{ target }}"
response_variable: shell
- stop: "Done"
response_variable: shell
mode: single
jellyfin-transport.sh) ที่ใช้ไฟล์คอนฟิกเดียวกัน โดยกลับด้านตรรกะการตรวจสอบ: สำหรับรายการที่กำลังเล่นอยู่แล้ว ตำแหน่งคือความจริงพื้นฐาน (หยุดชั่วคราว = ตำแหน่งนิ่งในสองตัวอย่าง เล่นต่อ = ตำแหน่งเดินหน้า หยุด = รายการหายไป) เพราะนั่นคือฟิลด์ที่ไคลเอนต์รายงานอย่างซื่อตรงในสถานะนั้น บรรทัดสุดท้ายที่เครื่องอ่านได้แบบเดียวกัน วินัยเรื่องงบเวลาแบบเดียวกัน และสำหรับวลีที่ใช้บ่อยที่สุด — “หยุด” “เล่นต่อ” — ตัวกระตุ้นแบบประโยคสามภาษาตอบก่อนที่จะได้ปรึกษาโมเดลภาษาด้วยซ้ำ: วลีตายตัวสมควรได้ชั้นที่เร็วที่สุดและแน่นอนที่สุดsecrets.env เป็น JELLYFINAPI พร้อมโทเคน Home Assistant แบบอายุยาวเป็น HA_TOKENplay_jellyfin.sh --sessions แล้วคัดลอกข้อความ CLIENT และ DEVICE ตรงตามที่แสดงไปใส่ใน jellyfin-targets.confmoonfin:// สำหรับ Android TV (พร้อมเอนทิตี remote ของมัน) และชื่อแอปจาก source_list สำหรับ tvOSscript.play_jellyfin ให้ Assist และปรับชื่อห้องในคำอธิบายฟิลด์ target ให้ตรงกับห้องของคุณPLAYING: