Skip to content

Commit 0b0d0b0

Browse files
authored
Merge pull request #193 from MerleLiuKun/feat-notification
feat(notification): ✨ add notification method
2 parents 5eae7cc + 2092f21 commit 0b0d0b0

6 files changed

Lines changed: 152 additions & 18 deletions

File tree

.github/workflows/docs.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ jobs:
99
runs-on: ubuntu-latest
1010
steps:
1111
- name: Checkout code
12-
uses: actions/checkout@v2
12+
uses: actions/checkout@v4
1313

1414
- name: Deploy docs
1515
uses: mhausenblas/mkdocs-deploy-gh-pages@master

.github/workflows/release.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ jobs:
77
build:
88
runs-on: ubuntu-latest
99
steps:
10-
- uses: actions/checkout@v2
10+
- uses: actions/checkout@v4
1111
- name: Build and publish to pypi
1212
uses: JRubics/poetry-publish@v1.17
1313
with:

.github/workflows/test.yaml

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@ on:
88

99
jobs:
1010
test:
11-
runs-on: ubuntu-20.04
11+
runs-on: ubuntu-latest
1212
strategy:
1313
matrix:
14-
python-version: ['3.6', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12']
14+
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
1515
include:
16-
- python-version: '3.8'
16+
- python-version: '3.12'
1717
update-coverage: true
1818

1919
steps:
@@ -23,7 +23,7 @@ jobs:
2323
with:
2424
python-version: ${{ matrix.python-version }}
2525
- name: Cache pip
26-
uses: actions/cache@v2
26+
uses: actions/cache@v4
2727
with:
2828
path: ~/.cache/pip
2929
key: ${{ matrix.python-version }}-poetry-${{ hashFiles('pyproject.toml') }}
@@ -36,7 +36,7 @@ jobs:
3636
poetry run pytest
3737
- name: Upload coverage to Codecov
3838
if: ${{ matrix.update-coverage }}
39-
uses: codecov/codecov-action@v4
39+
uses: codecov/codecov-action@v5
4040
with:
4141
file: ./coverage.xml
4242
fail_ci_if_error: true
@@ -47,12 +47,12 @@ jobs:
4747
runs-on: ubuntu-latest
4848

4949
steps:
50-
- uses: actions/checkout@v2
50+
- uses: actions/checkout@v6
5151
- uses: actions/setup-python@v2
5252
with:
53-
python-version: 3.8
53+
python-version: 3.12
5454
- name: Cache pip
55-
uses: actions/cache@v2
55+
uses: actions/cache@v4
5656
with:
5757
path: ~/.cache/pip
5858
key: lintenv-v2

examples/clients/oauth_refreshing.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,28 +12,28 @@
1212
CLIENT_SECRET = "xxx" # Your app secret
1313
CLIENT_SECRET_PATH = None # or your path/to/client_secret_web.json
1414

15-
TOKEN_PERSISTENT_PATH = None # path/to/persistent_token_storage_location
15+
TOKEN_PERSISTENT_PATH = None # path/to/persistent_token_storage_location
1616

1717
SCOPE = [
1818
"https://www.googleapis.com/auth/youtube",
1919
"https://www.googleapis.com/auth/youtube.force-ssl",
2020
"https://www.googleapis.com/auth/userinfo.profile",
2121
]
2222

23+
2324
def do_refresh():
2425
token_location = Path(TOKEN_PERSISTENT_PATH)
2526

2627
# Read the persistent token data if it exists
2728
token_data = {}
2829
if token_location.exists():
2930
token_data = loads(token_location.read_text())
30-
3131

3232
cli = Client(
3333
client_id=CLIENT_ID,
3434
client_secret=CLIENT_SECRET,
3535
access_token=token_data.get("access_token"),
36-
refresh_token=token_data.get("refresh_token")
36+
refresh_token=token_data.get("refresh_token"),
3737
)
3838
# or if you want to use a web type client_secret.json
3939
# cli = Client(
@@ -49,7 +49,9 @@ def do_refresh():
4949

5050
response_uri = input("Input youtube redirect uri:\n")
5151

52-
token = cli.generate_access_token(authorization_response=response_uri, scope=SCOPE)
52+
token = cli.generate_access_token(
53+
authorization_response=response_uri, scope=SCOPE
54+
)
5355
print(f"Your token: {token}")
5456

5557
# Otherwise, refresh the access token if it has expired
@@ -65,16 +67,14 @@ def do_refresh():
6567
token_location.mkdir(parents=True, exist_ok=True)
6668
token_location.write_text(
6769
dumps(
68-
{
69-
"access_token": token.access_token,
70-
"refresh_token": token.refresh_token
71-
}
70+
{"access_token": token.access_token, "refresh_token": token.refresh_token}
7271
)
7372
)
7473

7574
# Now you can do things with the client
7675
resp = cli.channels.list(mine=True)
7776
print(f"Your channel id: {resp.items[0].id}")
7877

78+
7979
if __name__ == "__main__":
8080
do_refresh()

pyyoutube/client.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ class Client:
3333
AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth"
3434
EXCHANGE_ACCESS_TOKEN_URL = "https://oauth2.googleapis.com/token"
3535
REVOKE_TOKEN_URL = "https://oauth2.googleapis.com/revoke"
36+
HUB_URL = "https://pubsubhubbub.appspot.com/subscribe"
3637

3738
DEFAULT_REDIRECT_URI = "https://localhost/"
3839
DEFAULT_SCOPE = [
@@ -520,3 +521,71 @@ def revoke_access_token(
520521
if response.ok:
521522
return True
522523
self.parse_response(response)
524+
525+
def subscribe_push_notification(
526+
self,
527+
channel_id: str,
528+
callback_url: str,
529+
mode: str = "subscribe",
530+
lease_seconds: Optional[int] = None,
531+
secret: Optional[str] = None,
532+
verify: str = "async",
533+
) -> bool:
534+
"""Subscribe or unsubscribe to a YouTube channel's push notifications via PubSubHubbub.
535+
536+
When a subscribed channel publishes a new video or updates an existing one,
537+
Google will send a notification to the callback_url.
538+
539+
Args:
540+
channel_id:
541+
The YouTube channel ID to subscribe to.
542+
callback_url:
543+
The URL that will receive push notifications from the hub.
544+
Must be publicly accessible.
545+
mode:
546+
Either "subscribe" or "unsubscribe".
547+
lease_seconds:
548+
How long (in seconds) the subscription should remain active.
549+
If omitted, the hub uses its own default (typically ~432000, i.e. 5 days).
550+
secret:
551+
A secret string used to compute an HMAC-SHA1 signature on each notification,
552+
allowing you to verify the payload came from the hub.
553+
verify:
554+
Verification mode. Either "async" (default) or "sync".
555+
556+
Returns:
557+
True if the hub accepted the request (HTTP 202 Accepted).
558+
559+
Raises:
560+
PyYouTubeException: If the hub returns an error response.
561+
562+
References:
563+
https://developers.google.com/youtube/v3/guides/push_notifications
564+
https://pubsubhubbub.github.io/PubSubHubbub/pubsubhubbub-core-0.4.html
565+
"""
566+
topic_url = (
567+
f"https://www.youtube.com/xml/feeds/videos.xml?channel_id={channel_id}"
568+
)
569+
570+
data = {
571+
"hub.callback": callback_url,
572+
"hub.mode": mode,
573+
"hub.topic": topic_url,
574+
"hub.verify": verify,
575+
}
576+
if lease_seconds is not None:
577+
data["hub.lease_seconds"] = str(lease_seconds)
578+
if secret is not None:
579+
data["hub.secret"] = secret
580+
581+
response = self.request(
582+
method="POST",
583+
path=self.HUB_URL,
584+
data=data,
585+
enforce_auth=False,
586+
)
587+
588+
# Hub returns 202 Accepted on success (async) or 204 No Content (sync)
589+
if response.status_code in (202, 204):
590+
return True
591+
self.parse_response(response)

tests/clients/test_client.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,68 @@ def test_oauth(self, helpers):
113113
status=400,
114114
)
115115
cli.revoke_access_token(token="token")
116+
117+
def test_subscribe_push_notification(self):
118+
HUB_URL = "https://pubsubhubbub.appspot.com/subscribe"
119+
cli = Client(client_id="id", client_secret="secret")
120+
121+
# subscribe returns True on 202 Accepted
122+
with responses.RequestsMock() as m:
123+
m.add(method="POST", url=HUB_URL, status=202)
124+
result = cli.subscribe_push_notification(
125+
channel_id="UCxxxxxx",
126+
callback_url="https://example.com/webhook",
127+
)
128+
assert result is True
129+
# verify hub.mode and hub.topic were sent correctly
130+
assert m.calls[0].request.body is not None
131+
assert "hub.mode=subscribe" in m.calls[0].request.body
132+
assert "UCxxxxxx" in m.calls[0].request.body
133+
134+
# unsubscribe returns True on 202 Accepted
135+
with responses.RequestsMock() as m:
136+
m.add(method="POST", url=HUB_URL, status=202)
137+
result = cli.subscribe_push_notification(
138+
channel_id="UCxxxxxx",
139+
callback_url="https://example.com/webhook",
140+
mode="unsubscribe",
141+
)
142+
assert result is True
143+
assert "hub.mode=unsubscribe" in m.calls[0].request.body
144+
145+
# sync verify returns True on 204 No Content
146+
with responses.RequestsMock() as m:
147+
m.add(method="POST", url=HUB_URL, status=204)
148+
result = cli.subscribe_push_notification(
149+
channel_id="UCxxxxxx",
150+
callback_url="https://example.com/webhook",
151+
verify="sync",
152+
)
153+
assert result is True
154+
155+
# optional params: lease_seconds and secret are included in request body
156+
with responses.RequestsMock() as m:
157+
m.add(method="POST", url=HUB_URL, status=202)
158+
cli.subscribe_push_notification(
159+
channel_id="UCxxxxxx",
160+
callback_url="https://example.com/webhook",
161+
lease_seconds=432000,
162+
secret="mysecret",
163+
)
164+
body = m.calls[0].request.body
165+
assert "hub.lease_seconds=432000" in body
166+
assert "hub.secret=mysecret" in body
167+
168+
# hub error raises PyYouTubeException
169+
with pytest.raises(PyYouTubeException):
170+
with responses.RequestsMock() as m:
171+
m.add(
172+
method="POST",
173+
url=HUB_URL,
174+
json={"error": {"code": 400, "message": "bad request"}},
175+
status=400,
176+
)
177+
cli.subscribe_push_notification(
178+
channel_id="UCxxxxxx",
179+
callback_url="https://example.com/webhook",
180+
)

0 commit comments

Comments
 (0)