chore: update watchlist features and fixes
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 422 KiB |
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 297 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 630 KiB |
|
After Width: | Height: | Size: 462 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 630 KiB |
|
After Width: | Height: | Size: 421 KiB |
@@ -0,0 +1 @@
|
||||
364: window.watchlistTabLoaded = false;
|
||||
@@ -0,0 +1,16 @@
|
||||
# Evidence: Task 1 - Timeout URL Test
|
||||
|
||||
## Scenario: Invalid video URL times out
|
||||
|
||||
**Tool**: Python3
|
||||
**Preconditions**: URL that times out (httpbin.org/delay/20)
|
||||
**Steps**:
|
||||
1. python3 -c "from app.downloaders.anime_sites.animesama import AnimeSamaDownloader; d = AnimeSamaDownloader(); result = d._test_video_url('https://httpbin.org/delay/20'); print(f'Result: {result}')"
|
||||
|
||||
**Expected Result**: Returns False (timeout)
|
||||
|
||||
**Actual Result**:
|
||||
Video URL validation FAILED: Timeout for https://httpbin.org/delay/20...
|
||||
Result for timeout URL: False
|
||||
|
||||
**Status**: PASS
|
||||
@@ -0,0 +1,15 @@
|
||||
# Evidence: Task 1 - Valid URL Test
|
||||
|
||||
## Scenario: Valid video URL returns 200 OK
|
||||
|
||||
**Tool**: Python3
|
||||
**Preconditions**: URL that returns HTTP 200
|
||||
**Steps**:
|
||||
1. python3 -c "from app.downloaders.anime_sites.animesama import AnimeSamaDownloader; d = AnimeSamaDownloader(); result = d._test_video_url('https://www.google.com/'); print(f'Result: {result}')"
|
||||
|
||||
**Expected Result**: Returns True
|
||||
|
||||
**Actual Result**:
|
||||
Result for google.com: True
|
||||
|
||||
**Status**: PASS
|
||||
@@ -0,0 +1,16 @@
|
||||
# Evidence: Task 2 - All Players Fail
|
||||
|
||||
## Scenario: All players fail
|
||||
|
||||
**Tool**: Python3
|
||||
**Preconditions**: Mock all extractions to fail
|
||||
**Steps**:
|
||||
1. Mock all _extract_from_* methods to raise Exception
|
||||
2. Call get_download_link_with_fallback()
|
||||
|
||||
**Expected Result**: Raises exception "All video players failed"
|
||||
|
||||
**Actual Result**:
|
||||
Exception raised: All players failed. Last error: Player failed
|
||||
|
||||
**Status**: PASS
|
||||
@@ -0,0 +1,42 @@
|
||||
# CSS Class Conflicts Check Results
|
||||
|
||||
## Check 4: filter-tab class in style.css
|
||||
No matches found for "filter-tab" in static/css/style.css
|
||||
|
||||
However, filter-tab IS defined in watchlist.html inline styles:
|
||||
/opt/Ohm_streaming/templates/watchlist.html
|
||||
123: .filter-tabs {
|
||||
130: .filter-tab {
|
||||
140: .filter-tab:hover {
|
||||
144: .filter-tab.active {
|
||||
257: <div class="filter-tabs">
|
||||
258: <button class="filter-tab active" onclick="filterWatchlist('all', this)">Tous</button>
|
||||
259: <button class="filter-tab" onclick="filterWatchlist('active', this)">Actifs</button>
|
||||
260: <button class="filter-tab" onclick="filterWatchlist('paused', this)">En pause</button>
|
||||
261: <button class="filter-tab" onclick="filterWatchlist('completed', this)">Terminés</button>
|
||||
363: document.querySelectorAll('.filter-tab').forEach(tab => {
|
||||
|
||||
## Check 5: .tab class in style.css
|
||||
Found 2 matches in static/css/style.css
|
||||
151: .tab {
|
||||
733: .tab {
|
||||
|
||||
## Tab Class Usage Across Templates:
|
||||
- login.html: auth-tabs, auth-tab
|
||||
- watchlist.html: .tab (navigation), .filter-tabs, .filter-tab
|
||||
- components/header.html: .tab (navigation tabs)
|
||||
|
||||
## Potential CSS Conflict Analysis:
|
||||
1. filter-tab: Defined inline in watchlist.html, NOT in style.css
|
||||
- Risk: LOW (isolated to watchlist page)
|
||||
|
||||
2. .tab: Defined in style.css at lines 151 and 733
|
||||
- Used in multiple templates for navigation tabs
|
||||
- .filter-tab is DIFFERENT from .tab
|
||||
- Risk: LOW (.tab and .filter-tab are distinct classes)
|
||||
|
||||
## Conclusion:
|
||||
NO CSS CLASS CONFLICTS DETECTED
|
||||
- filter-tab is isolated to watchlist.html (inline CSS)
|
||||
- .tab class in style.css is for main navigation tabs
|
||||
- .filter-tab is a separate, distinct class for watchlist filtering
|
||||
@@ -0,0 +1,32 @@
|
||||
# DOM ID Conflicts Check Results
|
||||
|
||||
## Check 1: watchlistContainer & schedulerStatus
|
||||
Found 2 matches in 1 file(s):
|
||||
/opt/Ohm_streaming/templates/watchlist.html
|
||||
233: <div class="scheduler-status" id="schedulerStatus">
|
||||
265: <div id="watchlistContainer">
|
||||
|
||||
## Check 2: settingsModal & nextRunInfo
|
||||
Found 2 matches in 1 file(s):
|
||||
/opt/Ohm_streaming/templates/watchlist.html
|
||||
237: <div id="nextRunInfo" class="next-run-info">Chargement...</div>
|
||||
422: <div id="settingsModal" style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); display: flex; align-items: center; justify-content: center; z-index: 1000;">
|
||||
|
||||
## Check 3: startSchedulerBtn & stopSchedulerBtn
|
||||
Found 2 matches in 1 file(s):
|
||||
/opt/Ohm_streaming/templates/watchlist.html
|
||||
240: <button id="startSchedulerBtn" class="btn-primary btn-small" onclick="handleStartScheduler()" style="display:none;">
|
||||
243: <button id="stopSchedulerBtn" class="btn-secondary btn-small" onclick="handleStopScheduler()" style="display:none;">
|
||||
|
||||
## Conflict Analysis:
|
||||
All IDs are unique to watchlist.html only. No conflicts found with other templates.
|
||||
|
||||
Checked templates:
|
||||
- login.html (auth-tabs, auth-tab)
|
||||
- index.html (tab-anime, tab-series, tab-providers)
|
||||
- components/header.html (mainTabs, tab-home, tab-anime, tab-series, etc.)
|
||||
- components/home_section.html (tab-home)
|
||||
- watchlist.html (these IDs are local to this file)
|
||||
|
||||
## Conclusion:
|
||||
NO DOM ID CONFLICTS DETECTED
|
||||
@@ -0,0 +1,19 @@
|
||||
# Evidence: Task 2 - First Player Works
|
||||
|
||||
## Scenario: First player (VidMoly) works
|
||||
|
||||
**Tool**: Python3
|
||||
**Preconditions**: Mock VidMoly URL that passes validation
|
||||
**Steps**:
|
||||
1. Mock _test_video_url to return True
|
||||
2. Mock _extract_from_vidmoly to return valid URL
|
||||
3. Call get_download_link_with_fallback()
|
||||
|
||||
**Expected Result**: Returns VidMoly URL, logs "VidMoly player succeeded"
|
||||
|
||||
**Actual Result**:
|
||||
Video URL: https://vidmoly.to/video.mp4
|
||||
Filename: vidmoly_video.mp4
|
||||
Used player: VidMoly
|
||||
|
||||
**Status**: PASS
|
||||
@@ -0,0 +1,19 @@
|
||||
# Evidence: Task 2 - Second Player Works
|
||||
|
||||
## Scenario: First player fails, second works
|
||||
|
||||
**Tool**: Python3
|
||||
**Preconditions**: Mock VidMoly to fail, SendVid to succeed
|
||||
**Steps**:
|
||||
1. Mock _extract_from_vidmoly to raise Exception
|
||||
2. Mock _extract_from_sendvid to return valid URL
|
||||
3. Mock _test_video_url to return True
|
||||
4. Call get_download_link_with_fallback()
|
||||
|
||||
**Expected Result**: Returns SendVid URL (VidMoly failed, SendVid succeeded)
|
||||
|
||||
**Actual Result**:
|
||||
Video URL: https://sendvid.com/video.mp4
|
||||
Used player: SendVid
|
||||
|
||||
**Status**: PASS
|
||||
@@ -0,0 +1,17 @@
|
||||
# Evidence: Task 3 - Direct URL Skips Fallback
|
||||
|
||||
## Scenario: Direct video URL skips fallback
|
||||
|
||||
**Tool**: Python3
|
||||
**Preconditions**: Anime-Sama downloader with fallback method
|
||||
**Steps**:
|
||||
1. Mock get_download_link_with_fallback
|
||||
2. Call get_download_link() with direct URL (no pipe)
|
||||
|
||||
**Expected Result**: Fallback method is NOT called (False) - direct extraction used
|
||||
|
||||
**Actual Result**:
|
||||
Fallback called: False
|
||||
Result: ('https://direct.mp4', 'direct.mp4')
|
||||
|
||||
**Status**: PASS
|
||||
@@ -0,0 +1,16 @@
|
||||
# Evidence: Task 3 - Pipe URL Triggers Fallback
|
||||
|
||||
## Scenario: Pipe URL triggers fallback
|
||||
|
||||
**Tool**: Python3
|
||||
**Preconditions**: Anime-Sama downloader with fallback method
|
||||
**Steps**:
|
||||
1. Mock get_download_link_with_fallback
|
||||
2. Call get_download_link() with pipe URL
|
||||
|
||||
**Expected Result**: Fallback method is called (True)
|
||||
|
||||
**Actual Result**:
|
||||
Fallback called: True
|
||||
|
||||
**Status**: PASS
|
||||
@@ -0,0 +1,93 @@
|
||||
# JavaScript Duplication Audit Report
|
||||
|
||||
**Generated:** 2026-02-26
|
||||
**Scope:** static/js/**/*.js (13 files)
|
||||
**Files Audited:** api.js, utils.js, auth.js, main.js, tabs.js, anime.js, series-search.js, downloads.js, watchlist/main.js, anime-details.js, recommendations.js, watchlist.js, watchlist-ui.js
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL DUPLICATIONS (Potential Syntax Errors)
|
||||
|
||||
### 1. translateStatus() Function - DUPLICATED DEFINITION
|
||||
- **File 1:** `static/js/utils.js:35` - Primary definition
|
||||
- **File 2:** `static/js/anime-details.js:428` - Duplicate definition
|
||||
|
||||
**Impact:** HIGH - If both files are loaded, the second definition will overwrite the first, causing unpredictable behavior. The utils.js version is used by downloads.js and recommendations.js, while anime-details.js has its own localized version.
|
||||
|
||||
**Recommendation:** Remove duplicate in anime-details.js and ensure anime-details.js imports from utils.js
|
||||
|
||||
---
|
||||
|
||||
## MINOR DUPLICATIONS (Non-Breaking)
|
||||
|
||||
### 2. Redundant const Declarations in Same Function Scope (Different Functions)
|
||||
|
||||
#### auth.js - Duplicate variable declarations across functions
|
||||
- `mainContent` declared at line 70 and line 76 (in different functions showMainContent/hideMainContent)
|
||||
- `userInfo` declared at line 57 and line 82 (in showUserInfo/showLoginPrompt)
|
||||
- `loginPrompt` declared at line 58 and line 83
|
||||
- `mainTabs` declared at line 59 and line 84
|
||||
|
||||
**Impact:** LOW - These are in different function scopes, not causing syntax errors but creating redundant code
|
||||
|
||||
#### recommendations.js - Duplicate variable names in different functions
|
||||
- `container` declared at lines 5, 54, 105 (in different functions)
|
||||
- `section` declared at lines 6, 55 (in different functions)
|
||||
|
||||
**Impact:** LOW - Different function scopes
|
||||
|
||||
#### tabs.js - Duplicate container variable
|
||||
- `container` declared at lines 115, 152, 160, 178, 186, 235, 252, 329
|
||||
|
||||
**Impact:** LOW - Different function scopes
|
||||
|
||||
#### anime.js - Duplicate variable names across functions
|
||||
- `selectElement` declared at lines 156, 245, 253, 261, 307, 352
|
||||
- `seasonSelectElement` declared at lines 156, 245
|
||||
- `actionsDiv` declared at lines 287, 325
|
||||
|
||||
**Impact:** LOW - Different function scopes
|
||||
|
||||
---
|
||||
|
||||
## PATTERN OBSERVATIONS
|
||||
|
||||
### Utility Functions Shared Across Files
|
||||
The following functions are defined once but used across multiple files:
|
||||
- `escapeHtml()` - Defined in utils.js:26, used in 8 files
|
||||
- `translateStatus()` - DEFINED TWICE (CRITICAL ISSUE)
|
||||
- `formatBytes()` - Defined in utils.js
|
||||
- `formatSpeed()` - Defined in utils.js
|
||||
- `extractSeriesName()` - Defined in utils.js
|
||||
- `getDayString()` - Defined in utils.js
|
||||
|
||||
### Cross-File Function Usage
|
||||
- `renderReleaseCard()` - Defined in recommendations.js:195, called in tabs.js:171
|
||||
- `renderAnimeCard()` - Defined in anime.js:58, called in anime-details.js
|
||||
- `loadDownloads()` - Defined in downloads.js, called from multiple files
|
||||
|
||||
---
|
||||
|
||||
## SUMMARY
|
||||
|
||||
| Severity | Count | Issue |
|
||||
|----------|-------|-------|
|
||||
| CRITICAL | 1 | translateStatus() defined twice (utils.js + anime-details.js) |
|
||||
| MINOR | 4+ | Redundant const declarations across functions (auth.js) |
|
||||
| MINOR | 3+ | Duplicate container/section variables (recommendations.js, tabs.js, anime.js) |
|
||||
|
||||
---
|
||||
|
||||
## RECOMMENDATIONS
|
||||
|
||||
1. **FIX CRITICAL:** Remove duplicate `translateStatus()` from anime-details.js and use the version from utils.js
|
||||
2. **Consider:** Consolidating utility functions into a single utils module that all files import
|
||||
3. **Future Cleanup:** Review auth.js for redundant variable declarations (minor optimization)
|
||||
|
||||
---
|
||||
|
||||
## VERIFICATION
|
||||
|
||||
Audit completed: 13 JavaScript files scanned
|
||||
Duplicate function definitions: 1 CRITICAL
|
||||
Redundant const declarations: Multiple (non-critical)
|
||||
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 418 KiB |
@@ -0,0 +1,111 @@
|
||||
# Task 5: Watchlist API Structure Documentation
|
||||
|
||||
## Base URL
|
||||
`http://localhost:3000/api/watchlist`
|
||||
|
||||
## Available Endpoints
|
||||
|
||||
### 1. GET /api/watchlist
|
||||
- **Description**: List all watchlist items for current user
|
||||
- **Auth**: Required (JWT Bearer token)
|
||||
- **Query Params**:
|
||||
- `status` (optional): Filter by status (active, paused, completed, archived)
|
||||
- **Response 200**: `{"watchlist": [], "total": 0, "filters": {"status": null}}`
|
||||
- **Response 403**: `{"detail": "Not authenticated"}`
|
||||
|
||||
### 2. POST /api/watchlist
|
||||
- **Description**: Add a new anime to the watchlist
|
||||
- **Auth**: Required
|
||||
- **Body**:
|
||||
```json
|
||||
{
|
||||
"anime_title": "string",
|
||||
"anime_url": "string",
|
||||
"provider_id": "string",
|
||||
"lang": "vostfr",
|
||||
"auto_download": true,
|
||||
"quality_preference": "auto",
|
||||
"poster_image": "string (optional)",
|
||||
"cover_image": "string (optional)",
|
||||
"synopsis": "string (optional)",
|
||||
"genres": ["string"] (optional)
|
||||
}
|
||||
```
|
||||
- **Response**: `{"status": "added", "item": {...}}`
|
||||
|
||||
### 3. GET /api/watchlist/{item_id}
|
||||
- **Description**: Get details of a specific watchlist item
|
||||
- **Auth**: Required
|
||||
- **Response 200**: `{"item": {...}}`
|
||||
- **Response 404**: `{"detail": "Watchlist item not found"}`
|
||||
- **Response 403**: `{"detail": "Access denied"}`
|
||||
|
||||
### 4. PUT /api/watchlist/{item_id}
|
||||
- **Description**: Update a watchlist item
|
||||
- **Auth**: Required
|
||||
- **Response**: `{"status": "updated", "item": {...}}`
|
||||
|
||||
### 5. DELETE /api/watchlist/{item_id}
|
||||
- **Description**: Remove an anime from the watchlist
|
||||
- **Auth**: Required
|
||||
- **Response**: `{"status": "deleted", "item_id": "string"}`
|
||||
|
||||
### 6. GET /api/watchlist/{item_id}/episodes
|
||||
- **Description**: Get all downloaded episodes for a watchlist item
|
||||
- **Auth**: Required
|
||||
|
||||
### 7. POST /api/watchlist/{item_id}/download/{episode}
|
||||
- **Description**: Download a specific episode
|
||||
- **Auth**: Required
|
||||
- **Response**: `{"status": "downloading", "task_id": "string", "episode": int, "item_id": "string"}`
|
||||
|
||||
### 8. GET /api/watchlist/stats ⚠️ BUG
|
||||
- **Description**: Get watchlist statistics
|
||||
- **Auth**: Required
|
||||
- **Expected Response**:
|
||||
```json
|
||||
{
|
||||
"total": 0,
|
||||
"active": 0,
|
||||
"paused": 0,
|
||||
"completed": 0,
|
||||
"archived": 0,
|
||||
"auto_download_enabled": 0,
|
||||
"total_episodes_downloaded": 0,
|
||||
"providers": {}
|
||||
}
|
||||
```
|
||||
- **Actual Response**: 404 "Watchlist item not found" (ROUTING BUG)
|
||||
|
||||
### 9. GET /api/watchlist/settings ⚠️ BUG
|
||||
- **Description**: Get watchlist settings
|
||||
- **Auth**: Required
|
||||
- **Expected Response**: `{"settings": {...}}`
|
||||
- **Actual Response**: 404 "Watchlist item not found" (ROUTING BUG)
|
||||
|
||||
### 10. GET /api/watchlist/notifications ⚠️ BUG
|
||||
- **Description**: Get user notifications
|
||||
- **Auth**: Required
|
||||
- **Query Params**: `unread_only` (bool)
|
||||
- **Expected Response**: `{"notifications": [], "total": 0, "unread_only": false}`
|
||||
- **Actual Response**: 404 "Watchlist item not found" (ROUTING BUG)
|
||||
|
||||
### 11. PUT /api/watchlist/notifications/{notification_id}/read
|
||||
- **Description**: Mark a notification as read
|
||||
- **Auth**: Required
|
||||
|
||||
### 12. PUT /api/watchlist/settings
|
||||
- **Description**: Update watchlist settings
|
||||
- **Auth**: Required
|
||||
|
||||
## Authentication
|
||||
- Uses JWT Bearer tokens
|
||||
- Token obtained from POST /api/auth/login
|
||||
- Pass as: `Authorization: Bearer <token>`
|
||||
|
||||
## Bug Summary
|
||||
- **Issue**: 3 endpoints return 404 instead of correct responses
|
||||
- **Affected**: /stats, /settings, /notifications
|
||||
- **Cause**: Route ordering - `/{item_id}` catch-all defined before these specific routes
|
||||
- **Location**: app/routes/watchlist.py
|
||||
- **Fix needed**: Move specific routes BEFORE the `/{item_id}` route
|
||||
@@ -0,0 +1,19 @@
|
||||
# Evidence: Task 5 - Integration Test with Real Anime-Sama URL
|
||||
|
||||
## Scenario: Download Frieren S1 E1 with fallback
|
||||
|
||||
**Tool**: curl + API
|
||||
**Preconditions**: Server running, fallback implemented
|
||||
**Steps**:
|
||||
1. Get episodes from anime-sama.tv
|
||||
2. Download episode via API
|
||||
|
||||
**Expected Result**: Download completes successfully
|
||||
|
||||
**Actual Result**:
|
||||
- Download status: COMPLETED
|
||||
- File size: 321MB
|
||||
- File: downloads/Frieren - S1 - Episode 01.mp4
|
||||
- Logs show: Using SendVid for extraction (fallback working)
|
||||
|
||||
**Status**: PASS
|
||||
@@ -0,0 +1,41 @@
|
||||
# Task 5: GET /api/watchlist Test Results
|
||||
|
||||
## Test Date: 2026-02-26
|
||||
|
||||
## Server Status
|
||||
- Server running on port 3000: ✓
|
||||
- Health check: ✓ PASS
|
||||
|
||||
## Authentication Test
|
||||
- Unauthenticated request to /api/watchlist:
|
||||
- HTTP Status: 403
|
||||
- Response: {"detail":"Not authenticated"}
|
||||
|
||||
- Authenticated request to /api/watchlist:
|
||||
- HTTP Status: 200
|
||||
- Response: {"watchlist":[],"total":0,"filters":{"status":null}}
|
||||
|
||||
## Endpoints Tested
|
||||
|
||||
| Endpoint | Auth | Expected Status | Actual Status | Result |
|
||||
|----------|------|-----------------|---------------|--------|
|
||||
| GET /api/watchlist | No | 401/403 | 403 | ✓ PASS |
|
||||
| GET /api/watchlist | Yes | 200 | 200 | ✓ PASS |
|
||||
| GET /api/watchlist/stats | Yes | 200 | 404 | ✗ FAIL (BUG) |
|
||||
| GET /api/watchlist/settings | Yes | 200 | 404 | ✗ FAIL (BUG) |
|
||||
| GET /api/watchlist/notifications | Yes | 200 | 404 | ✗ FAIL (BUG) |
|
||||
|
||||
## Issue Found
|
||||
|
||||
The following endpoints return 404 "Watchlist item not found" when they should work:
|
||||
- /api/watchlist/stats
|
||||
- /api/watchlist/settings
|
||||
- /api/watchlist/notifications
|
||||
|
||||
**Root Cause**: Route ordering issue in `app/routes/watchlist.py`
|
||||
- The `/{item_id}` catch-all route (line 134) is defined BEFORE the specific routes like `/stats` (line 372), `/settings` (line 335), and `/notifications` (line 285)
|
||||
- FastAPI matches these paths as item IDs instead of the intended routes
|
||||
|
||||
## Test User
|
||||
- Username: watchlist_test
|
||||
- Token: JWT (7-day expiry)
|
||||
@@ -0,0 +1,14 @@
|
||||
Watchlist Integration Test Results
|
||||
============================================================
|
||||
[PASS] Navigate to /watchlist
|
||||
[PASS] Watchlist tab highlighted
|
||||
[PASS] Header/nav present
|
||||
[PASS] Scheduler panel displays
|
||||
[PASS] Filter tabs present and clickable
|
||||
[PASS] Settings modal works
|
||||
[PASS] Refresh mechanism present
|
||||
[PASS] Tab switching works
|
||||
[PASS] /web#watchlist loads watchlist
|
||||
[PASS] /watchlist page has content
|
||||
============================================================
|
||||
Total: 10/10 tests passed
|
||||
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 418 KiB |