Arkm20 commited on
Commit
671dd1e
·
verified ·
1 Parent(s): e4a4b82

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +179 -208
app.py CHANGED
@@ -4,7 +4,7 @@ import uuid
4
  import secrets
5
  import zipfile
6
  import io
7
- import httpx # For making async API calls to Jikan
8
  from datetime import datetime, timedelta
9
  from typing import List, Dict, Optional, Any
10
 
@@ -16,28 +16,29 @@ from fastapi.middleware.cors import CORSMiddleware
16
  from jose import JWTError, jwt
17
  from passlib.context import CryptContext
18
  from pydantic import BaseModel, Field
19
- from fastapi.templating import Jinja2Templates
20
 
21
  # --- Configuration ---
 
22
  JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY", secrets.token_hex(32))
23
  ALGORITHM = "HS256"
24
  ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
25
 
26
- # --- Persistent Data Paths ---
27
  DATA_DIR = "data"
28
  USERS_DB_FILE = os.path.join(DATA_DIR, "users.json")
29
  UPLOAD_DIR = os.path.join(DATA_DIR, "uploads")
30
  STATIC_DIR = "static"
31
 
 
32
  os.makedirs(DATA_DIR, exist_ok=True)
33
  os.makedirs(UPLOAD_DIR, exist_ok=True)
34
- os.makedirs(STATIC_DIR, exist_ok=True) # Ensure static dir exists
35
 
36
  # --- Security & Hashing ---
37
  pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
38
  oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
39
 
40
- # --- Pydantic Models ---
41
  class Token(BaseModel):
42
  access_token: str
43
  token_type: str
@@ -72,7 +73,7 @@ class PasswordChange(BaseModel):
72
  current_password: str
73
  new_password: str
74
 
75
- # --- Database Helper Functions ---
76
  def load_users() -> Dict[str, Dict]:
77
  if not os.path.exists(USERS_DB_FILE):
78
  return {}
@@ -100,9 +101,13 @@ def get_password_hash(password):
100
 
101
  def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
102
  to_encode = data.copy()
103
- expire_time = datetime.utcnow() + (expires_delta if expires_delta else timedelta(minutes=15))
104
- to_encode.update({"exp": expire_time})
105
- return jwt.encode(to_encode, JWT_SECRET_KEY, algorithm=ALGORITHM)
 
 
 
 
106
 
107
  # --- Dependency to get current user ---
108
  async def get_current_user(token: str = Depends(oauth2_scheme)) -> UserInDB:
@@ -120,14 +125,16 @@ async def get_current_user(token: str = Depends(oauth2_scheme)) -> UserInDB:
120
  except JWTError:
121
  raise credentials_exception
122
 
123
- user = load_users().get(token_data.username)
124
- if user is None:
 
125
  raise credentials_exception
126
- return UserInDB(**user)
 
 
127
 
128
  # --- FastAPI App Initialization ---
129
- app = FastAPI(title="Anime PWA API")
130
- templates = Jinja2Templates(directory=STATIC_DIR)
131
 
132
  app.add_middleware(
133
  CORSMiddleware,
@@ -137,12 +144,12 @@ app.add_middleware(
137
  allow_headers=["*"],
138
  )
139
 
140
- # --- Helper Functions ---
141
  def structure_watch_history(history_list: List[Dict]) -> Dict:
142
  structured = {}
143
  sorted_history = sorted(history_list, key=lambda x: x.get("watch_timestamp", ""), reverse=True)
 
144
  for item in sorted_history:
145
- # ... (rest of your existing function)
146
  show_id = item.get("show_id")
147
  show_title = item.get("show_title", "Unknown Show")
148
  season_num = item.get("season_number")
@@ -170,19 +177,17 @@ async def get_anime_poster_url(anime_title: str) -> Optional[str]:
170
  """Fetches the top anime poster URL from Jikan API."""
171
  try:
172
  async with httpx.AsyncClient() as client:
173
- # Using Jikan API v4
174
  response = await client.get(f"https://api.jikan.moe/v4/anime?q={anime_title}&limit=1")
175
  response.raise_for_status()
176
  data = response.json()
177
  if data.get("data"):
178
- # Get the large JPG image URL
179
  return data["data"][0]["images"]["jpg"]["large_image_url"]
180
  except Exception as e:
181
  print(f"Error fetching poster for '{anime_title}': {e}")
182
- return None # Return None on failure
183
  return None
184
 
185
- # --- HTML Content ---
186
  DOWNLOAD_UI_HTML = """
187
  <!DOCTYPE html>
188
  <html lang="en">
@@ -192,124 +197,32 @@ DOWNLOAD_UI_HTML = """
192
  <title>Download Anime Series</title>
193
  <style>
194
  @import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;700&display=swap');
195
- body {
196
- font-family: 'Roboto', sans-serif;
197
- background-color: #141414;
198
- color: #fff;
199
- margin: 0;
200
- padding: 2rem;
201
- display: flex;
202
- justify-content: center;
203
- align-items: center;
204
- min-height: 100vh;
205
- }
206
- .container {
207
- background: #1c1c1c;
208
- padding: 2rem;
209
- border-radius: 12px;
210
- box-shadow: 0 10px 30px rgba(0,0,0,0.5);
211
- width: 100%;
212
- max-width: 600px;
213
- }
214
- h1 {
215
- color: #E50914; /* Anime/Netflix Red */
216
- text-align: center;
217
- margin-bottom: 2rem;
218
- font-weight: 700;
219
- }
220
- .series-list {
221
- list-style: none;
222
- padding: 0;
223
- max-height: 40vh;
224
- overflow-y: auto;
225
- border: 1px solid #333;
226
- border-radius: 8px;
227
- }
228
- .series-item {
229
- display: flex;
230
- align-items: center;
231
- padding: 1rem;
232
- border-bottom: 1px solid #333;
233
- cursor: pointer;
234
- transition: background-color 0.2s ease;
235
- }
236
- .series-item:last-child { border-bottom: none; }
237
- .series-item:hover { background-color: #2a2a2a; }
238
- .series-item input[type="checkbox"] {
239
- margin-right: 1rem;
240
- width: 20px;
241
- height: 20px;
242
- accent-color: #E50914;
243
- }
244
- .series-item label {
245
- flex-grow: 1;
246
- font-size: 1.1rem;
247
- }
248
- .button-container {
249
- text-align: center;
250
- margin-top: 2rem;
251
- }
252
- .btn {
253
- background-color: #E50914;
254
- color: white;
255
- border: none;
256
- padding: 1rem 2rem;
257
- font-size: 1.2rem;
258
- border-radius: 8px;
259
- cursor: pointer;
260
- transition: background-color 0.2s ease;
261
- font-weight: 700;
262
- }
263
- .btn:hover { background-color: #f6121d; }
264
- .btn:disabled {
265
- background-color: #555;
266
- cursor: not-allowed;
267
- }
268
- /* Loading Animation */
269
- #loading-overlay {
270
- position: fixed;
271
- top: 0; left: 0;
272
- width: 100%; height: 100%;
273
- background: rgba(0,0,0,0.85);
274
- display: none;
275
- flex-direction: column;
276
- justify-content: center;
277
- align-items: center;
278
- z-index: 1000;
279
- }
280
- .loader {
281
- border: 8px solid #f3f3f3;
282
- border-top: 8px solid #E50914;
283
- border-radius: 50%;
284
- width: 80px;
285
- height: 80px;
286
- animation: spin 1s linear infinite;
287
- }
288
- #loading-text {
289
- color: #fff;
290
- margin-top: 20px;
291
- font-size: 1.2rem;
292
- }
293
- @keyframes spin {
294
- 0% { transform: rotate(0deg); }
295
- 100% { transform: rotate(360deg); }
296
- }
297
  </style>
298
  </head>
299
  <body>
300
- <div id="loading-overlay">
301
- <div class="loader"></div>
302
- <p id="loading-text">Generating your files... Please wait.</p>
303
- </div>
304
  <div class="container">
305
  <h1>Select Anime to Download</h1>
306
  <form id="downloadForm">
307
- <ul id="seriesList" class="series-list">
308
- <!-- Series will be populated by JavaScript -->
309
- </ul>
310
- <div class="button-container">
311
- <button type="submit" class="btn" id="generateBtn" disabled>Generate Zip</button>
312
- </div>
313
  </form>
314
  </div>
315
 
@@ -318,15 +231,21 @@ DOWNLOAD_UI_HTML = """
318
  const seriesList = document.getElementById('seriesList');
319
  const generateBtn = document.getElementById('generateBtn');
320
  const loadingOverlay = document.getElementById('loading-overlay');
321
- const token = localStorage.getItem('accessToken'); // Assuming you store the JWT here
 
 
 
 
322
 
323
  if (!token) {
324
- seriesList.innerHTML = '<p>Error: You are not logged in. Please log in to see your watch history.</p>';
 
325
  return;
326
  }
327
 
328
  try {
329
- const response = await fetch('/users/me', {
 
330
  headers: { 'Authorization': `Bearer ${token}` }
331
  });
332
 
@@ -336,61 +255,46 @@ DOWNLOAD_UI_HTML = """
336
 
337
  const userData = await response.json();
338
  const history = userData.watch_history_detailed;
339
-
340
- const uniqueSeries = {};
341
- Object.values(history).forEach(show => {
342
- uniqueSeries[show.title] = show.title; // Use title as key to ensure uniqueness
343
- });
344
-
345
- const sortedSeriesTitles = Object.keys(uniqueSeries).sort();
346
 
347
- if (sortedSeriesTitles.length === 0) {
348
- seriesList.innerHTML = '<li class="series-item"><label>No watched series found.</label></li>';
349
  return;
350
  }
351
 
352
- sortedSeriesTitles.forEach(title => {
353
  const listItem = document.createElement('li');
354
  listItem.className = 'series-item';
355
- listItem.innerHTML = `
356
- <input type="checkbox" id="${title}" name="series" value="${title}">
357
- <label for="${title}">${title}</label>
358
- `;
359
  seriesList.appendChild(listItem);
360
  });
361
 
362
  generateBtn.disabled = false;
363
 
364
  } catch (error) {
365
- seriesList.innerHTML = `<p style="color: #E50914; text-align: center;">${error.message}</p>`;
366
  }
367
 
368
  document.getElementById('downloadForm').addEventListener('submit', (e) => {
369
  e.preventDefault();
370
- loadingOverlay.style.display = 'flex'; // Show loading screen
371
 
372
- const selectedSeries = Array.from(document.querySelectorAll('input[name="series"]:checked'))
373
- .map(cb => cb.value);
374
 
375
  if (selectedSeries.length === 0) {
376
  alert('Please select at least one series.');
377
- loadingOverlay.style.display = 'none'; // Hide loading screen
378
  return;
379
  }
380
 
381
- // Construct the query string
382
- const queryString = new URLSearchParams({
383
- series_titles: selectedSeries.join(',')
384
- }).toString();
385
 
386
- // Use window.open to trigger the download endpoint
387
- const downloadUrl = `/generate-zip?${queryString}&token=${token}`;
388
  window.open(downloadUrl, '_blank');
389
 
390
- // Hide the loading overlay after a short delay to allow the new window to open
391
- setTimeout(() => {
392
- loadingOverlay.style.display = 'none';
393
- }, 3000);
394
  });
395
  });
396
  </script>
@@ -399,136 +303,203 @@ DOWNLOAD_UI_HTML = """
399
  """
400
 
401
  # --- API Endpoints ---
 
402
  @app.post("/token", response_model=Token, tags=["Authentication"])
403
  async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()):
404
  users_db = load_users()
405
- user = users_db.get(form_data.username)
406
- if not user or not verify_password(form_data.password, user.get("hashed_password")):
407
  raise HTTPException(
408
  status_code=status.HTTP_401_UNAUTHORIZED,
409
  detail="Incorrect username or password",
 
410
  )
411
- token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
412
- access_token = create_access_token(data={"sub": user["username"]}, expires_delta=token_expires)
 
 
413
  return {"access_token": access_token, "token_type": "bearer"}
414
 
 
415
  @app.post("/signup", status_code=status.HTTP_201_CREATED, tags=["Authentication"])
416
  async def signup_user(user: UserCreate):
417
  users_db = load_users()
418
  if user.username in users_db:
419
- raise HTTPException(status_code=400, detail="Username already registered")
420
-
 
 
 
421
  new_user = UserInDB(
422
  username=user.username,
423
- hashed_password=get_password_hash(user.password),
 
 
424
  )
425
  users_db[user.username] = new_user.dict()
426
  save_users(users_db)
427
  return {"message": "User created successfully. Please login."}
428
 
 
429
  @app.get("/users/me", response_model=UserPublic, tags=["User"])
430
  async def read_users_me(current_user: UserInDB = Depends(get_current_user)):
431
  detailed_history = structure_watch_history(current_user.watch_history)
432
- return UserPublic(
 
433
  username=current_user.username,
434
  email=current_user.username,
435
  profile_picture_url=current_user.profile_picture_url,
436
  watch_history_detailed=detailed_history
437
  )
 
 
438
 
439
  @app.post("/users/me/profile-picture", response_model=UserPublic, tags=["User"])
440
  async def upload_profile_picture(
441
- file: UploadFile = File(...), current_user: UserInDB = Depends(get_current_user)
 
442
  ):
443
- # ... (Your existing profile picture logic)
444
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
445
 
446
  @app.get("/users/me/watch-history", status_code=status.HTTP_200_OK, tags=["User"])
447
  async def update_watch_history(
448
- show_id: str = Query(...), show_title: str = Query(...),
449
- season_number: int = Query(..., ge=0), episode_number: int = Query(..., ge=1),
 
 
450
  current_user: UserInDB = Depends(get_current_user)
451
  ):
452
- # ... (Your existing watch history logic)
453
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
454
 
455
  @app.post("/users/me/password", status_code=status.HTTP_200_OK, tags=["User"])
456
  async def change_user_password(
457
- password_data: PasswordChange, current_user: UserInDB = Depends(get_current_user)
 
458
  ):
459
- # ... (Your existing password change logic)
460
- pass
 
 
 
 
 
 
 
 
 
 
 
 
461
 
462
- # --- NEW: Endpoints for Download UI and Zip Generation ---
463
 
464
  @app.get("/download-ui", response_class=HTMLResponse, tags=["Download"])
465
  async def get_download_ui(request: Request):
466
  """Serves the modern HTML interface for selecting downloads."""
467
  return HTMLResponse(content=DOWNLOAD_UI_HTML)
468
 
 
469
  @app.get("/generate-zip", tags=["Download"])
470
  async def generate_zip_file(
471
  series_titles: str = Query(..., description="Comma-separated list of anime titles"),
472
- token: str = Query(..., description="User's auth token")
473
  ):
474
  """
475
  Generates a zip file containing folders for selected anime series,
476
- each with a poster.png inside.
477
  """
478
- # Authenticate the user via the token in the query parameter
 
 
 
 
479
  try:
480
- await get_current_user(token)
 
481
  except HTTPException:
482
- raise HTTPException(status_code=401, detail="Authentication failed")
483
 
484
- titles = [title.strip() for title in series_titles.split(',')]
485
 
486
- # In-memory buffer for the zip file
487
  zip_buffer = io.BytesIO()
488
 
489
  async with httpx.AsyncClient() as client:
490
  with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zipf:
491
  for title in titles:
492
- if not title: continue
493
-
494
- # Fetch poster URL
495
- poster_url = await get_anime_poster_url(title)
496
-
497
  # Sanitize title for folder name
498
  safe_folder_name = "".join(c for c in title if c.isalnum() or c in " .-_").rstrip()
499
  folder_path = f"Anime/{safe_folder_name}/"
500
 
 
 
501
  if poster_url:
502
  try:
503
- # Download the poster image
504
  response = await client.get(poster_url)
505
  response.raise_for_status()
506
- # Add poster to the zip file inside the series folder
507
  zipf.writestr(f"{folder_path}poster.png", response.content)
508
  except Exception as e:
509
  print(f"Failed to download or write poster for '{title}': {e}")
510
- # Create an empty folder even if the poster fails
511
  zipf.writestr(f"{folder_path}.placeholder", "")
512
  else:
513
- # If no poster is found, still create the folder
514
  zipf.writestr(f"{folder_path}.placeholder", "")
515
 
516
- # Seek to the beginning of the buffer
517
  zip_buffer.seek(0)
518
 
519
  return StreamingResponse(
520
  zip_buffer,
521
  media_type="application/zip",
522
- headers={"Content-Disposition": "attachment; filename=anime_series.zip"}
523
  )
524
 
525
-
526
- # --- Static File Serving (Must be last) ---
527
  app.mount("/uploads", StaticFiles(directory=UPLOAD_DIR), name="uploads")
528
- # We remove the generic "/" mount to explicitly define our routes
529
- # app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
530
 
531
  @app.get("/", include_in_schema=False)
532
- async def root():
533
- """Redirects the root to the download UI for this example."""
534
- return RedirectResponse(url="/download-ui")
 
4
  import secrets
5
  import zipfile
6
  import io
7
+ import httpx
8
  from datetime import datetime, timedelta
9
  from typing import List, Dict, Optional, Any
10
 
 
16
  from jose import JWTError, jwt
17
  from passlib.context import CryptContext
18
  from pydantic import BaseModel, Field
 
19
 
20
  # --- Configuration ---
21
+ # In a real production app, use Hugging Face Space Secrets to set this!
22
  JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY", secrets.token_hex(32))
23
  ALGORITHM = "HS256"
24
  ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
25
 
26
+ # --- Persistent Data Paths (Hugging Face Spaces use /data for persistent storage) ---
27
  DATA_DIR = "data"
28
  USERS_DB_FILE = os.path.join(DATA_DIR, "users.json")
29
  UPLOAD_DIR = os.path.join(DATA_DIR, "uploads")
30
  STATIC_DIR = "static"
31
 
32
+ # Create persistent directories if they don't exist
33
  os.makedirs(DATA_DIR, exist_ok=True)
34
  os.makedirs(UPLOAD_DIR, exist_ok=True)
35
+ os.makedirs(STATIC_DIR, exist_ok=True)
36
 
37
  # --- Security & Hashing ---
38
  pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
39
  oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
40
 
41
+ # --- Pydantic Models (Data Schemas) ---
42
  class Token(BaseModel):
43
  access_token: str
44
  token_type: str
 
73
  current_password: str
74
  new_password: str
75
 
76
+ # --- Database Helper Functions (using JSON file) ---
77
  def load_users() -> Dict[str, Dict]:
78
  if not os.path.exists(USERS_DB_FILE):
79
  return {}
 
101
 
102
  def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
103
  to_encode = data.copy()
104
+ if expires_delta:
105
+ expire = datetime.utcnow() + expires_delta
106
+ else:
107
+ expire = datetime.utcnow() + timedelta(minutes=15)
108
+ to_encode.update({"exp": expire})
109
+ encoded_jwt = jwt.encode(to_encode, JWT_SECRET_KEY, algorithm=ALGORITHM)
110
+ return encoded_jwt
111
 
112
  # --- Dependency to get current user ---
113
  async def get_current_user(token: str = Depends(oauth2_scheme)) -> UserInDB:
 
125
  except JWTError:
126
  raise credentials_exception
127
 
128
+ users_db = load_users()
129
+ user_data = users_db.get(token_data.username)
130
+ if user_data is None:
131
  raise credentials_exception
132
+
133
+ return UserInDB(**user_data)
134
+
135
 
136
  # --- FastAPI App Initialization ---
137
+ app = FastAPI(title="Media Auth API")
 
138
 
139
  app.add_middleware(
140
  CORSMiddleware,
 
144
  allow_headers=["*"],
145
  )
146
 
147
+ # --- Helper function to structure watch history ---
148
  def structure_watch_history(history_list: List[Dict]) -> Dict:
149
  structured = {}
150
  sorted_history = sorted(history_list, key=lambda x: x.get("watch_timestamp", ""), reverse=True)
151
+
152
  for item in sorted_history:
 
153
  show_id = item.get("show_id")
154
  show_title = item.get("show_title", "Unknown Show")
155
  season_num = item.get("season_number")
 
177
  """Fetches the top anime poster URL from Jikan API."""
178
  try:
179
  async with httpx.AsyncClient() as client:
 
180
  response = await client.get(f"https://api.jikan.moe/v4/anime?q={anime_title}&limit=1")
181
  response.raise_for_status()
182
  data = response.json()
183
  if data.get("data"):
 
184
  return data["data"][0]["images"]["jpg"]["large_image_url"]
185
  except Exception as e:
186
  print(f"Error fetching poster for '{anime_title}': {e}")
187
+ return None
188
  return None
189
 
190
+ # --- HTML Content for Download UI ---
191
  DOWNLOAD_UI_HTML = """
192
  <!DOCTYPE html>
193
  <html lang="en">
 
197
  <title>Download Anime Series</title>
198
  <style>
199
  @import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;700&display=swap');
200
+ body{font-family:'Roboto',sans-serif;background-color:#141414;color:#fff;margin:0;padding:2rem;display:flex;justify-content:center;align-items:center;min-height:100vh}
201
+ .container{background:#1c1c1c;padding:2rem;border-radius:12px;box-shadow:0 10px 30px rgba(0,0,0,0.5);width:100%;max-width:600px}
202
+ h1{color:#E50914;text-align:center;margin-bottom:2rem;font-weight:700}
203
+ .series-list{list-style:none;padding:0;max-height:40vh;overflow-y:auto;border:1px solid #333;border-radius:8px}
204
+ .series-item{display:flex;align-items:center;padding:1rem;border-bottom:1px solid #333;cursor:pointer;transition:background-color 0.2s ease}
205
+ .series-item:last-child{border-bottom:none}
206
+ .series-item:hover{background-color:#2a2a2a}
207
+ .series-item input[type="checkbox"]{margin-right:1rem;width:20px;height:20px;accent-color:#E50914}
208
+ .series-item label{flex-grow:1;font-size:1.1rem}
209
+ .button-container{text-align:center;margin-top:2rem}
210
+ .btn{background-color:#E50914;color:white;border:none;padding:1rem 2rem;font-size:1.2rem;border-radius:8px;cursor:pointer;transition:background-color 0.2s ease;font-weight:700}
211
+ .btn:hover{background-color:#f6121d}
212
+ .btn:disabled{background-color:#555;cursor:not-allowed}
213
+ #loading-overlay{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.85);display:none;flex-direction:column;justify-content:center;align-items:center;z-index:1000}
214
+ .loader{border:8px solid #f3f3f3;border-top:8px solid #E50914;border-radius:50%;width:80px;height:80px;animation:spin 1s linear infinite}
215
+ #loading-text{color:#fff;margin-top:20px;font-size:1.2rem}
216
+ @keyframes spin{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  </style>
218
  </head>
219
  <body>
220
+ <div id="loading-overlay"><div class="loader"></div><p id="loading-text">Generating your files...</p></div>
 
 
 
221
  <div class="container">
222
  <h1>Select Anime to Download</h1>
223
  <form id="downloadForm">
224
+ <ul id="seriesList" class="series-list"></ul>
225
+ <div class="button-container"><button type="submit" class="btn" id="generateBtn" disabled>Generate Zip</button></div>
 
 
 
 
226
  </form>
227
  </div>
228
 
 
231
  const seriesList = document.getElementById('seriesList');
232
  const generateBtn = document.getElementById('generateBtn');
233
  const loadingOverlay = document.getElementById('loading-overlay');
234
+ const apiBaseUrl = ''; // API is at the same origin
235
+
236
+ // Get the token from the URL parameters instead of localStorage.
237
+ const urlParams = new URLSearchParams(window.location.search);
238
+ const token = urlParams.get('token');
239
 
240
  if (!token) {
241
+ seriesList.innerHTML = '<li class="series-item" style="justify-content: center; color: #E50914;">Authentication token not found in URL.</li>';
242
+ generateBtn.disabled = true;
243
  return;
244
  }
245
 
246
  try {
247
+ // Use the token from the URL for the Bearer authentication header.
248
+ const response = await fetch(`${apiBaseUrl}/users/me`, {
249
  headers: { 'Authorization': `Bearer ${token}` }
250
  });
251
 
 
255
 
256
  const userData = await response.json();
257
  const history = userData.watch_history_detailed;
258
+ const uniqueSeries = [...new Set(Object.values(history).map(show => show.title))].sort();
 
 
 
 
 
 
259
 
260
+ if (uniqueSeries.length === 0) {
261
+ seriesList.innerHTML = '<li class="series-item" style="justify-content: center;">No watched series found.</li>';
262
  return;
263
  }
264
 
265
+ uniqueSeries.forEach(title => {
266
  const listItem = document.createElement('li');
267
  listItem.className = 'series-item';
268
+ listItem.innerHTML = `<input type="checkbox" id="${title}" name="series" value="${title}"><label for="${title}">${title}</label>`;
 
 
 
269
  seriesList.appendChild(listItem);
270
  });
271
 
272
  generateBtn.disabled = false;
273
 
274
  } catch (error) {
275
+ seriesList.innerHTML = `<li class="series-item" style="justify-content: center; color: #E50914;">${error.message}</li>`;
276
  }
277
 
278
  document.getElementById('downloadForm').addEventListener('submit', (e) => {
279
  e.preventDefault();
280
+ loadingOverlay.style.display = 'flex';
281
 
282
+ const selectedSeries = Array.from(document.querySelectorAll('input[name="series"]:checked')).map(cb => cb.value);
 
283
 
284
  if (selectedSeries.length === 0) {
285
  alert('Please select at least one series.');
286
+ loadingOverlay.style.display = 'none';
287
  return;
288
  }
289
 
290
+ const queryString = new URLSearchParams({ series_titles: selectedSeries.join(',') }).toString();
291
+
292
+ // Construct the download URL, passing the token as a query parameter as required by the /generate-zip endpoint.
293
+ const downloadUrl = `${apiBaseUrl}/generate-zip?${queryString}&token=${token}`;
294
 
 
 
295
  window.open(downloadUrl, '_blank');
296
 
297
+ setTimeout(() => { loadingOverlay.style.display = 'none'; }, 3000);
 
 
 
298
  });
299
  });
300
  </script>
 
303
  """
304
 
305
  # --- API Endpoints ---
306
+
307
  @app.post("/token", response_model=Token, tags=["Authentication"])
308
  async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()):
309
  users_db = load_users()
310
+ user_data = users_db.get(form_data.username)
311
+ if not user_data or not verify_password(form_data.password, user_data.get("hashed_password")):
312
  raise HTTPException(
313
  status_code=status.HTTP_401_UNAUTHORIZED,
314
  detail="Incorrect username or password",
315
+ headers={"WWW-Authenticate": "Bearer"},
316
  )
317
+ access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
318
+ access_token = create_access_token(
319
+ data={"sub": user_data["username"]}, expires_delta=access_token_expires
320
+ )
321
  return {"access_token": access_token, "token_type": "bearer"}
322
 
323
+
324
  @app.post("/signup", status_code=status.HTTP_201_CREATED, tags=["Authentication"])
325
  async def signup_user(user: UserCreate):
326
  users_db = load_users()
327
  if user.username in users_db:
328
+ raise HTTPException(
329
+ status_code=status.HTTP_400_BAD_REQUEST,
330
+ detail="Username already registered",
331
+ )
332
+ hashed_password = get_password_hash(user.password)
333
  new_user = UserInDB(
334
  username=user.username,
335
+ hashed_password=hashed_password,
336
+ profile_picture_url=None,
337
+ watch_history=[]
338
  )
339
  users_db[user.username] = new_user.dict()
340
  save_users(users_db)
341
  return {"message": "User created successfully. Please login."}
342
 
343
+
344
  @app.get("/users/me", response_model=UserPublic, tags=["User"])
345
  async def read_users_me(current_user: UserInDB = Depends(get_current_user)):
346
  detailed_history = structure_watch_history(current_user.watch_history)
347
+
348
+ user_public_data = UserPublic(
349
  username=current_user.username,
350
  email=current_user.username,
351
  profile_picture_url=current_user.profile_picture_url,
352
  watch_history_detailed=detailed_history
353
  )
354
+ return user_public_data
355
+
356
 
357
  @app.post("/users/me/profile-picture", response_model=UserPublic, tags=["User"])
358
  async def upload_profile_picture(
359
+ file: UploadFile = File(...),
360
+ current_user: UserInDB = Depends(get_current_user)
361
  ):
362
+ file_extension = os.path.splitext(file.filename)[1].lower()
363
+ if file_extension not in ['.png', '.jpg', '.jpeg', '.gif', '.webp']:
364
+ raise HTTPException(status_code=400, detail="Invalid file type.")
365
+
366
+ unique_filename = f"{uuid.uuid4()}{file_extension}"
367
+ file_path = os.path.join(UPLOAD_DIR, unique_filename)
368
+
369
+ with open(file_path, "wb") as buffer:
370
+ buffer.write(await file.read())
371
+
372
+ profile_picture_url = f"/uploads/{unique_filename}"
373
+ users_db = load_users()
374
+ users_db[current_user.username]["profile_picture_url"] = profile_picture_url
375
+ save_users(users_db)
376
+
377
+ current_user.profile_picture_url = profile_picture_url
378
+ detailed_history = structure_watch_history(current_user.watch_history)
379
+ return UserPublic(
380
+ username=current_user.username,
381
+ email=current_user.username,
382
+ profile_picture_url=current_user.profile_picture_url,
383
+ watch_history_detailed=detailed_history
384
+ )
385
+
386
 
387
  @app.get("/users/me/watch-history", status_code=status.HTTP_200_OK, tags=["User"])
388
  async def update_watch_history(
389
+ show_id: str = Query(...),
390
+ show_title: str = Query(...),
391
+ season_number: int = Query(..., ge=0),
392
+ episode_number: int = Query(..., ge=1),
393
  current_user: UserInDB = Depends(get_current_user)
394
  ):
395
+ users_db = load_users()
396
+ user_data = users_db[current_user.username]
397
+ episode_id = f"{show_id}_{season_number}_{episode_number}"
398
+
399
+ is_already_watched = any(
400
+ (f"{item.get('show_id')}_{item.get('season_number')}_{item.get('episode_number')}" == episode_id)
401
+ for item in user_data.get("watch_history", [])
402
+ )
403
+
404
+ if not is_already_watched:
405
+ new_entry = WatchHistoryEntry(
406
+ show_id=show_id,
407
+ show_title=show_title,
408
+ season_number=season_number,
409
+ episode_number=episode_number,
410
+ watch_timestamp=datetime.utcnow()
411
+ )
412
+ user_data.setdefault("watch_history", []).append(new_entry.dict())
413
+ save_users(users_db)
414
+ return {"message": "Watch history updated."}
415
+
416
+ return {"message": "Episode already in watch history."}
417
+
418
 
419
  @app.post("/users/me/password", status_code=status.HTTP_200_OK, tags=["User"])
420
  async def change_user_password(
421
+ password_data: PasswordChange,
422
+ current_user: UserInDB = Depends(get_current_user)
423
  ):
424
+ users_db = load_users()
425
+ user_data = users_db[current_user.username]
426
+
427
+ if not verify_password(password_data.current_password, user_data["hashed_password"]):
428
+ raise HTTPException(
429
+ status_code=status.HTTP_400_BAD_REQUEST,
430
+ detail="Incorrect current password",
431
+ )
432
+
433
+ new_hashed_password = get_password_hash(password_data.new_password)
434
+ user_data["hashed_password"] = new_hashed_password
435
+ save_users(users_db)
436
+
437
+ return {"message": "Password updated successfully"}
438
 
 
439
 
440
  @app.get("/download-ui", response_class=HTMLResponse, tags=["Download"])
441
  async def get_download_ui(request: Request):
442
  """Serves the modern HTML interface for selecting downloads."""
443
  return HTMLResponse(content=DOWNLOAD_UI_HTML)
444
 
445
+
446
  @app.get("/generate-zip", tags=["Download"])
447
  async def generate_zip_file(
448
  series_titles: str = Query(..., description="Comma-separated list of anime titles"),
449
+ token: str = Query(..., description="User's auth token from URL param")
450
  ):
451
  """
452
  Generates a zip file containing folders for selected anime series,
453
+ each with a poster.png inside. It authenticates using the token from the URL.
454
  """
455
+ # Create a temporary dependency to validate the token from the query parameter
456
+ async def get_user_from_query_token(token_str: str = token) -> UserInDB:
457
+ # This re-uses the logic from get_current_user but works on a raw token string
458
+ return await get_current_user(token=token_str)
459
+
460
  try:
461
+ # Validate the token by calling our temporary dependency
462
+ await get_user_from_query_token()
463
  except HTTPException:
464
+ raise HTTPException(status_code=401, detail="Authentication failed from URL token")
465
 
466
+ titles = [title.strip() for title in series_titles.split(',') if title.strip()]
467
 
 
468
  zip_buffer = io.BytesIO()
469
 
470
  async with httpx.AsyncClient() as client:
471
  with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zipf:
472
  for title in titles:
 
 
 
 
 
473
  # Sanitize title for folder name
474
  safe_folder_name = "".join(c for c in title if c.isalnum() or c in " .-_").rstrip()
475
  folder_path = f"Anime/{safe_folder_name}/"
476
 
477
+ poster_url = await get_anime_poster_url(title)
478
+
479
  if poster_url:
480
  try:
 
481
  response = await client.get(poster_url)
482
  response.raise_for_status()
 
483
  zipf.writestr(f"{folder_path}poster.png", response.content)
484
  except Exception as e:
485
  print(f"Failed to download or write poster for '{title}': {e}")
486
+ # Create an empty file to ensure folder creation
487
  zipf.writestr(f"{folder_path}.placeholder", "")
488
  else:
489
+ # If no poster is found, still create the folder via a placeholder
490
  zipf.writestr(f"{folder_path}.placeholder", "")
491
 
 
492
  zip_buffer.seek(0)
493
 
494
  return StreamingResponse(
495
  zip_buffer,
496
  media_type="application/zip",
497
+ headers={"Content-Disposition": "attachment; filename=anime_series_folders.zip"}
498
  )
499
 
500
+ # --- Static File Serving & Root Redirect ---
 
501
  app.mount("/uploads", StaticFiles(directory=UPLOAD_DIR), name="uploads")
 
 
502
 
503
  @app.get("/", include_in_schema=False)
504
+ def root():
505
+ return RedirectResponse(url="/docs")