给孩子排一份看片计划:工作日晚上 19:00 最多看 30 分钟,周末上午最多 90 分钟。脚本把片单按集排进这些时间段,生成
.ics导入家庭日历,事件里会写明用哪个档案观看(比如开启了 Junior Mode 的儿童档案),观看时长的管理和 Disney+ 自带的家长控制配合使用。
先看清成品
pytest 跑了 4 个测试,全部通过;示例片单从 2026-10-22(周四)开始排,周四、周五各 1 集,周六 2 集,周日 1 部 85 分钟的电影,一共 5 个事件,用
icalendar解析通过。片名是虚构的示例,没有连接 Disney+,也没有在真实的日历 App 里导入测试过。家长控制的说明来自帮助中心页面(2026-10-21 查看)。
注意:Disney+ 没有面向个人开发者的公开 API。本系列只用官方帮助中心公布的数字和规则,以及你自己填写的数据。不要用 VPN 或代理切换地区,不要绕过 DRM、不要录屏或导出下载文件,不要在家庭以外共享账号(以订阅协议为准)。套餐价格和可用地区以官方页面为准,本文不列数字。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
watch_plan.py |
按时间段和时长上限排片,生成 .ics |
watchlist.json |
档案名、时间段和片单(片名、集数、每集分钟数) |
| 报错保护 | 一集比时间段还长时直接报错,不会排成死循环 |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| Python | 3.8 以上 | 只用标准库(测试用 pytest) |
| Disney+ 档案 | 建议给孩子单独建一个档案 | 在网页版或手机 App 里设置家长控制 |
第 1 步:先在 Disney+ 里设好家长控制
帮助中心列出的功能有:Content ratings(每个档案的内容分级)、Junior Mode、Profile PIN、Profile creation restriction(限制新建档案),以及 Not Rated & Live Content 设置。页面说明这些只能在支持的网页浏览器或 Disney+ 手机 App 里管理,路径是 EDIT PROFILES → 选档案 → PARENTAL CONTROLS。另外,Junior Mode 档案不支持内容分级设置。
这个脚本只负责“什么时候看、看多久”,内容是否适合孩子交给上面这些官方功能来管。
文件结构
34-disney-plus-C-src/
├── watch_plan.py
├── watchlist.json
└── test_plan.py
第 2 步:脚本
watch_plan.py:
"""把家庭片单排成日历(.ics),导入手机或电脑日历。
不连接 Disney+、不读账号数据:片名、集数和时长都由你自己在 watchlist.json 里填写。
用法:python watch_plan.py watchlist.json 开始日期YYYY-MM-DD [输出.ics]"""
import hashlib, json, sys
from datetime import date, datetime, timedelta
def schedule(cfg, start):
"""每天按 slots 的时间段排片;每个时间段的时长 = 分钟上限,放得下几集就排几集。"""
queue = [(w["title"], ep, w["minutes"]) for w in cfg["watchlist"] for ep in range(1, w["episodes"] + 1)]
for t, _, m in queue:
if m <= 0: raise ValueError(f"{t}:minutes 必须大于 0")
events, day = [], start
while queue:
slot = cfg["slots"]["weekend" if day.weekday() >= 5 else "weekday"]
if slot:
hh, mm = map(int, slot["start"].split(":"))
t, used = datetime.combine(day, datetime.min.time()).replace(hour=hh, minute=mm), 0
while queue and used + queue[0][2] <= slot["max_minutes"]:
title, ep, m = queue.pop(0)
events.append((title, ep, t, t + timedelta(minutes=m)))
t += timedelta(minutes=m); used += m
if used == 0:
raise ValueError(f"《{queue[0][0]}》一集 {queue[0][2]} 分钟,超过时间段上限 {slot['max_minutes']} 分钟")
day += timedelta(days=1)
return events
def esc(s): return s.replace("\\", "\\\\").replace(",", "\\,").replace(";", "\\;")
def to_ics(events, profile, stamp="20261021T000000Z"):
out = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//binbho//watch-plan//ZH"]
for title, ep, s, e in events:
out += ["BEGIN:VEVENT", f"UID:{s:%Y%m%dT%H%M}-{ep}-{hashlib.sha1(title.encode()).hexdigest()[:8]}@watch-plan", f"DTSTAMP:{stamp}",
f"DTSTART:{s:%Y%m%dT%H%M%S}", f"DTEND:{e:%Y%m%dT%H%M%S}",
f"SUMMARY:{esc(title)} 第{ep}集", f"DESCRIPTION:{esc('用「' + profile + '」档案观看')}", "END:VEVENT"]
return "\r\n".join(out + ["END:VCALENDAR"]) + "\r\n"
if __name__ == "__main__":
cfg = json.load(open(sys.argv[1], encoding="utf-8"))
ev = schedule(cfg, date.fromisoformat(sys.argv[2]))
for title, ep, s, e in ev: print(f"{s:%m-%d %a %H:%M}-{e:%H:%M} {title} 第{ep}集")
open(sys.argv[3] if len(sys.argv) > 3 else "plan.ics", "w", encoding="utf-8", newline="").write(to_ics(ev, cfg["profile"]))
watchlist.json:
{
"profile": "孩子(Junior Mode)",
"slots": {"weekday": {"start": "19:00", "max_minutes": 30},
"weekend": {"start": "10:00", "max_minutes": 90}},
"watchlist": [
{"title": "示例动画剧集", "episodes": 4, "minutes": 22},
{"title": "示例动画电影", "episodes": 1, "minutes": 85}
]
}
第 3 步:测试和运行
test_plan.py:
from datetime import date
import pytest
from watch_plan import schedule, to_ics
CFG = {"profile": "p", "slots": {"weekday": {"start": "19:00", "max_minutes": 30}, "weekend": {"start": "10:00", "max_minutes": 90}},
"watchlist": [{"title": "S", "episodes": 4, "minutes": 22}, {"title": "M, 电影", "episodes": 1, "minutes": 85}]}
def test_schedule():
ev = schedule(CFG, date(2026, 10, 22)) # 周四
days = [s.date().isoformat() for _, _, s, _ in ev]
assert days == ["2026-10-22", "2026-10-23", "2026-10-24", "2026-10-24", "2026-10-25"]
assert ev[3][2].strftime("%H:%M") == "10:22"
def test_too_long():
bad = dict(CFG, slots={"weekday": {"start": "19:00", "max_minutes": 30}, "weekend": {"start": "10:00", "max_minutes": 60}})
with pytest.raises(ValueError, match="超过时间段上限"): schedule(bad, date(2026, 10, 22))
def test_ics_escape():
s = to_ics(schedule(CFG, date(2026, 10, 22)), "p")
assert "SUMMARY:M\\, 电影 第1集" in s and s.count("BEGIN:VEVENT") == 5
def test_uid_stable():
import subprocess, sys
code = "from watch_plan import *;from test_plan import CFG;from datetime import date;print(to_ics(schedule(CFG,date(2026,10,22)),'p'))"
a, b = (subprocess.run([sys.executable, "-c", code], capture_output=True, text=True).stdout for _ in range(2))
assert a == b
$ python -m pytest -q
4 passed
$ python watch_plan.py watchlist.json 2026-10-22
10-22 Thu 19:00-19:22 示例动画剧集 第1集
10-23 Fri 19:00-19:22 示例动画剧集 第2集
10-24 Sat 10:00-10:22 示例动画剧集 第3集
10-24 Sat 10:22-10:44 示例动画剧集 第4集
10-25 Sun 10:00-11:25 示例动画电影 第1集
检查点:周六两集紧挨着排(10:22 开始第 4 集),加起来 44 分钟,没有超过 90 分钟;电影 85 分钟放不进工作日的 30 分钟,所以排到了周日。片名里的逗号在 .ics 里转义成了 ``(见测试)。
调试示例:每次生成的 UID 都不一样
第一版用 hash(title) 拼 UID。连续运行两次,同一个事件的 UID 分别是:
UID:20261022T1900-1-10926250@watch-plan
UID:20261022T1900-1-40459461@watch-plan
原因是 Python 的字符串 hash 每个进程都会带随机盐。UID 一变,日历 App 就会把重新导入的事件当成新事件,结果全部重复。改成 hashlib.sha1(title) 的前 8 位以后,UID 就稳定了;test_uid_stable 会启动两个进程,比较两次的输出是否完全一致。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
超过时间段上限 |
某一集(或电影)比所有时间段都长 | 调大周末的 max_minutes,或者把这部片拿出来单独安排 |
| 日历里的时间差了几个小时 | 事件没有带时区,按设备的本地时间显示 | 在同一个时区的设备上导入 |
| 孩子切换到了大人的档案 | 档案没有加锁 | 用 Profile PIN 给大人的档案加锁 |
模板规则
- 时长由计划管理,内容由官方家长控制管理,两者配合使用。
- 片单自己填写,不要爬取 Disney+ 的片库。
- UID 要稳定,重新导入时才不会重复。
项目资源
| 用途 | 地址 |
|---|---|
| 家长控制 | https://help.disneyplus.com/article/disneyplus-parental-controls |
| iCalendar 规范 RFC 5545 | https://www.rfc-editor.org/rfc/rfc5545 |
| icalendar(用于验证) | https://pypi.org/project/icalendar/ |
| Disney+ 订阅协议 | https://www.disneyplus.com/legal/subscriber-agreement |
一个 JSON 文件加上一个日历文件,就能把“每天看多久”排得清清楚楚。账号和内容分级仍然交给 Disney+ 自己的家长控制。