-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathmain.py
371 lines (308 loc) · 12.7 KB
/
main.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
"""
YouTube Tasks Browser application main module.
This module provides the core functionality for authenticating with Google APIs
and managing the interaction between Google Tasks and YouTube data.
"""
# Google API client is not liked by pylint
# pylint: disable=maybe-no-member
import re
import pickle
import base64
from pathlib import Path
import os
import dotenv
from nicegui import ui, app as ng_app
from google_auth_oauthlib.flow import Flow
from google.auth.transport.requests import Request as GRequest
from googleapiclient.discovery import build
from starlette.responses import RedirectResponse
from fastapi import Request
from app_ui import show_login_ui, show_main_ui
# OAuth 2.0 configuration
SCOPES = [
"https://www.googleapis.com/auth/tasks.readonly",
"https://www.googleapis.com/auth/youtube.readonly",
]
class App:
"""
Main application class handling authentication and API interactions.
Manages Google OAuth2 credentials, authentication flow, and provides
methods for fetching and processing tasks and YouTube video data.
"""
def __init__(self):
self.credentials = None # Single source of truth for credentials
self.client_secrets_path = Path("client_secrets.json")
self.auth_flow = None
self.credentials_path = Path("stored_credentials.pickle")
self._load_stored_credentials()
self.dark_mode = False # Add dark mode state
self.sorting_criteria = "Alphabetical"
def toggle_dark_mode(self):
"""Toggle dark mode state."""
self.dark_mode = not self.dark_mode
# 1) Apply dark mode
ui.dark_mode(self.dark_mode)
# 2) Update cookie on client side
cookie_val = "1" if self.dark_mode else "0"
ui.run_javascript(f"document.cookie = 'dark_mode={cookie_val};path=/'")
print("Toggled dark mode, cookie set to:", cookie_val)
def _load_stored_credentials(self):
"""Load stored credentials and refresh if needed."""
try:
if self.credentials_path.exists():
with open(self.credentials_path, "rb") as f:
credentials = pickle.load(f)
if credentials and credentials.expired and credentials.refresh_token:
print("Refreshing expired credentials")
credentials.refresh(GRequest())
self.save_credentials(credentials)
self.credentials = credentials
print(
f"Loaded credentials, valid: {bool(credentials and not credentials.expired)}"
)
except Exception as e: # pylint: disable=broad-except
print(f"Error loading credentials: {e}")
self.credentials_path.unlink(missing_ok=True)
self.credentials = None
def save_credentials(self, credentials):
"""Save credentials to file."""
if credentials:
print("Saving credentials")
with open(self.credentials_path, "wb") as f:
pickle.dump(credentials, f)
def has_client_secrets(self):
"""Check if client_secrets.json file exists."""
return self.client_secrets_path.exists()
def is_authenticated(self):
"""Check if user is authenticated."""
return bool(
self.credentials and not self.credentials.expired and self.credentials.valid
)
async def authenticate(self, request: Request = None):
"""
Initiate OAuth2 authentication flow.
Args:
request: FastAPI request object to determine the current host
"""
if not self.has_client_secrets():
ui.notify("Missing client_secrets.json file", type="negative")
return
# Determine the base URL from the request, fallback to localhost
if request:
base_url = str(request.base_url).rstrip("/")
else:
base_url = "http://localhost:8080"
redirect_uri = f"{base_url}/oauth2callback"
print(f"Redirect URI: {redirect_uri}")
self.auth_flow = Flow.from_client_secrets_file(
self.client_secrets_path,
scopes=SCOPES,
redirect_uri=redirect_uri,
)
auth_url, _ = self.auth_flow.authorization_url(
prompt="consent",
access_type="offline",
include_granted_scopes="true",
)
ui.notify("Redirecting to Google for authentication", type="info")
ui.navigate.to(f"{auth_url}")
def extract_youtube_urls(self, text):
"""Extract YouTube URLs from text."""
if not text:
return []
# Match various YouTube URL formats
youtube_regex = r"(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]+)" # pylint: disable=line-too-long
return re.findall(youtube_regex, text)
async def fetch_tasks_with_videos(self):
"""Fetch all tasks and extract ones with YouTube URLs."""
if not self.credentials:
return []
service = build("tasks", "v1", credentials=self.credentials)
tasks_with_videos = []
# Get all task lists with pagination
tasklists = []
page_token = None
while True:
response = service.tasklists().list(pageToken=page_token).execute()
tasklists.extend(response.get("items", []))
page_token = response.get("nextPageToken")
if not page_token:
break
for tasklist in tasklists:
# Get all tasks in the task list with pagination
page_token = None
while True:
response = (
service.tasks()
.list(
tasklist=tasklist["id"], showHidden=True, pageToken=page_token
)
.execute()
)
tasks = response.get("items", [])
for task in tasks:
if task.get("status") == "completed":
continue
youtube_urls = []
# Check title and notes for YouTube URLs
youtube_urls.extend(
self.extract_youtube_urls(task.get("title", ""))
)
youtube_urls.extend(
self.extract_youtube_urls(task.get("notes", ""))
)
if youtube_urls:
tasks_with_videos.append(
{
"task_list": tasklist["title"],
"task_list_id": tasklist["id"],
"task_id": task["id"],
"task_url": task.get("webViewLink", ""),
"task_title": task.get("title", ""),
"task_notes": task.get("notes", ""),
"youtube_ids": youtube_urls,
"status": task.get("status", ""),
"due": task.get("due", ""),
}
)
page_token = response.get("nextPageToken")
if not page_token:
break
return tasks_with_videos
async def get_video_details(self, video_ids):
"""Fetch video details from YouTube API."""
if not self.credentials or not video_ids:
return {}
youtube = build("youtube", "v3", credentials=self.credentials)
video_details = {}
# Process videos in batches of 50 (API limit)
for i in range(0, len(video_ids), 50):
batch = video_ids[i : i + 50]
request = youtube.videos().list(
part="snippet,contentDetails", id=",".join(batch)
)
response = request.execute()
for item in response.get("items", []):
video_details[item["id"]] = {
"title": item["snippet"]["title"],
"thumbnail": item["snippet"]["thumbnails"]["medium"],
"channel": item["snippet"]["channelTitle"],
"channelId": item["snippet"]["channelId"], # Add channelId
"duration": item["contentDetails"]["duration"],
"publishedAt": item["snippet"]["publishedAt"],
}
return video_details
app = App()
def store_credentials_in_browser(credentials):
"""
Store current credentials in local storage as base64-encoded pickle.
Note: This can be a security risk. Use carefully.
"""
data = pickle.dumps(credentials)
encoded = base64.b64encode(data).decode("utf-8")
ng_app.storage.browser["yt_credentials"] = encoded
print("Stored credentials in browser local storage.")
def load_credentials_from_browser():
"""
Load credentials from local storage if present.
Returns:
Credentials object or None if not found
"""
encoded = ng_app.storage.browser.get("yt_credentials", None)
if encoded:
data = base64.b64decode(encoded.encode("utf-8"))
loaded = pickle.loads(data)
print("Loaded credentials from browser local storage.")
return loaded
return None
@ui.page("/")
async def main(request: Request):
"""
Main application route handler.
Displays either the login UI or main application interface based on
authentication status.
"""
# 3) Read the 'dark_mode' cookie on page load
dark_mode_cookie = request.cookies.get("dark_mode")
sorting_criteria = request.cookies.get("sorting_criteria")
app.sorting_criteria = sorting_criteria
if dark_mode_cookie == "1":
app.dark_mode = True
else:
app.dark_mode = False
# 4) Apply dark mode based on cookie before continuing
ui.dark_mode(app.dark_mode)
# Try loading from browser storage if we don't have valid credentials
if not (app.credentials and app.is_authenticated()):
retrieved = load_credentials_from_browser()
print("Retrieved credentials from browser storage: ", retrieved)
if retrieved and not retrieved.expired and retrieved.valid:
print("Using retrieved credentials")
app.credentials = retrieved
else:
print("No valid credentials found")
print("Test Is Authenticate? ", app.is_authenticated())
if app.is_authenticated():
await show_main_ui(app)
else:
await show_login_ui(app, request)
@ui.page("/oauth2callback")
def oauth2callback(request: Request):
"""
OAuth2 callback handler for Google authentication.
Args:
request: FastAPI request object containing OAuth2 response data
Returns:
RedirectResponse: Redirects to main page after handling authentication
"""
print("\n=== OAuth2 Callback Started ===")
try:
params = request.query_params
code = params.get("code")
if code:
print(f"Received auth code: {code[:10]}...")
else:
print("No code received!")
if not app.auth_flow:
print("Error: Authentication flow not initialized")
return RedirectResponse("/")
print("Exchanging code for credentials...")
app.auth_flow.fetch_token(code=code)
credentials = app.auth_flow.credentials
print(f"Credentials obtained, valid: {credentials.valid}")
app.save_credentials(credentials)
app.credentials = credentials
app.auth_flow = None
# Store credentials in local storage so a server restart won't break user session
store_credentials_in_browser(credentials)
print("Authentication completed successfully")
return RedirectResponse("/")
except Exception as e: # pylint: disable=broad-except
print(f"Authentication error: {str(e)}")
print(f"Error type: {type(e)}")
return RedirectResponse("/")
if __name__ in {"__main__", "__mp_main__"}:
# Step 1) Load environment variables
dotenv.load_dotenv()
# Step 2) Check for STORAGE_SECRET in environment
secret = os.getenv("STORAGE_SECRET")
# Step 3) If not found, read from file
if not secret:
secret_file = Path("credentials") / "storage_secret"
if secret_file.exists():
secret = secret_file.read_text().strip()
# Step 2) Get PORT from environment (fallback to default if not found)
port_str = os.getenv("PORT")
if port_str is not None:
PORT = int(port_str)
HOST = "0.0.0.0"
else:
PORT = 8080 # NiceGUI default
HOST = "127.0.0.1" # NiceGUI default
# Step 4) Pass port to ui.run(...)
ui.run(
title="YouTube Videos from Google Tasks",
storage_secret=secret,
port=PORT,
host=HOST,
)