feat: Complete watchlist & auto-download system with UI
Implement comprehensive watchlist system with automatic episode detection
and downloading. Features include per-user watchlists, scheduler-based
periodic checks, and a modern web UI.
**Backend Components:**
- WatchlistManager: JSON-based storage with multi-tenant support
- EpisodeChecker: Detects and downloads new episodes automatically
- AutoDownloadScheduler: APScheduler-based periodic task execution
- Complete REST API for CRUD operations and scheduler control
**Frontend Components:**
- Modern watchlist page with dark theme and animations
- Real-time status updates and progress tracking
- Scheduler controls with next-run display
- Add anime directly from search results
**Models & Configuration:**
- WatchlistItem with status, quality, and auto-download settings
- WatchlistSettings for global configuration
- Per-user statistics and provider tracking
**API Endpoints:**
- GET/POST /api/watchlist - List and add items
- PUT/DELETE /api/watchlist/{id} - Update and delete
- POST /api/watchlist/{id}/check - Manual check trigger
- POST /api/watchlist/check-all - Check all due items
- GET/PUT /api/watchlist/settings - Global settings
- GET /api/watchlist/stats - Statistics
- GET/POST /api/watchlist/scheduler/* - Scheduler control
**Configuration Files:**
- config/watchlist.json - User watchlist data
- config/watchlist_settings.json - Global settings
Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
Unit tests for utility functions (app/utils.py)
|
||||
Tests filename sanitization and security validation
|
||||
"""
|
||||
import pytest
|
||||
from app.utils import sanitize_filename, is_safe_filename
|
||||
|
||||
|
||||
class TestSanitizeFilename:
|
||||
"""Tests for sanitize_filename function"""
|
||||
|
||||
def test_sanitize_simple_filename(self):
|
||||
"""Test sanitizing a simple, safe filename"""
|
||||
filename = "simple_video.mp4"
|
||||
result = sanitize_filename(filename)
|
||||
assert result == "simple_video.mp4"
|
||||
|
||||
def test_sanitize_with_dangerous_chars(self):
|
||||
"""Test removal of dangerous characters"""
|
||||
# Test each dangerous character
|
||||
assert sanitize_filename("file\\name.mp4") == "file_name.mp4"
|
||||
assert sanitize_filename("file/name.mp4") == "file_name.mp4"
|
||||
assert sanitize_filename("file:name.mp4") == "file_name.mp4"
|
||||
assert sanitize_filename("file*name.mp4") == "file_name.mp4"
|
||||
assert sanitize_filename("file?name.mp4") == "file_name.mp4"
|
||||
assert sanitize_filename('file"name.mp4') == "file_name.mp4"
|
||||
assert sanitize_filename("file<name>.mp4") == "file_name_.mp4"
|
||||
assert sanitize_filename("file|name.mp4") == "file_name.mp4"
|
||||
|
||||
def test_sanitize_all_dangerous_chars(self):
|
||||
"""Test filename with all dangerous characters"""
|
||||
filename = 'file\\/:*?"<>|name.mp4'
|
||||
result = sanitize_filename(filename)
|
||||
assert result == "file________name.mp4"
|
||||
|
||||
def test_sanitize_path_traversal(self):
|
||||
"""Test path traversal attempts are blocked"""
|
||||
# Parent directory traversal
|
||||
assert sanitize_filename("../../../etc/passwd") == "______etc_passwd"
|
||||
assert sanitize_filename("../../secret.txt") == "____secret.txt"
|
||||
|
||||
# Current directory reference
|
||||
assert sanitize_filename("./file.txt") == "file.txt"
|
||||
assert sanitize_filename(".hidden") == "hidden"
|
||||
|
||||
# Absolute path attempts
|
||||
assert sanitize_filename("/etc/passwd") == "passwd"
|
||||
assert sanitize_filename("\\windows\\system32") == "system32"
|
||||
|
||||
def test_sanitize_leading_dots_and_dashes(self):
|
||||
"""Test removal of leading dots and dashes"""
|
||||
assert sanitize_filename(".hidden") == "hidden"
|
||||
assert sanitize_filename("..hidden") == "hidden"
|
||||
assert sanitize_filename("---file.txt") == "file.txt"
|
||||
assert sanitize_filename("...test...mp4") == "test...mp4" # Only leading
|
||||
|
||||
def test_sanitize_empty_filename(self):
|
||||
"""Test empty filename returns default"""
|
||||
assert sanitize_filename("") == "download"
|
||||
assert sanitize_filename(" ") == "download"
|
||||
|
||||
def test_sanitize_only_dangerous_chars(self):
|
||||
"""Test filename with only dangerous characters"""
|
||||
assert sanitize_filename("\\/:*?\"<>|") == "download"
|
||||
|
||||
def test_sanitize_length_limit(self):
|
||||
"""Test filename length is limited"""
|
||||
# Create a very long filename
|
||||
long_name = "a" * 300 + ".mp4"
|
||||
result = sanitize_filename(long_name, max_length=255)
|
||||
assert len(result) <= 255
|
||||
assert result.endswith(".mp4")
|
||||
|
||||
def test_sanitize_length_limit_preserves_extension(self):
|
||||
"""Test that extension is preserved when limiting length"""
|
||||
long_name = "x" * 260 + ".mp4"
|
||||
result = sanitize_filename(long_name, max_length=255)
|
||||
assert result.endswith(".mp4")
|
||||
# Name part is truncated but extension kept
|
||||
name, ext = result.rsplit(".", 1)
|
||||
assert len(name) + len(ext) + 1 == 255
|
||||
|
||||
def test_sanitize_unicode(self):
|
||||
"""Test sanitization with unicode characters"""
|
||||
# Japanese characters
|
||||
assert sanitize_filename("アニメ.mp4") == "アニメ.mp4"
|
||||
# Accented characters
|
||||
assert sanitize_filename("café.mp4") == "café.mp4"
|
||||
# Emoji
|
||||
assert sanitize_filename("video🎬.mp4") == "video🎬.mp4"
|
||||
|
||||
def test_sanitize_multiple_extensions(self):
|
||||
"""Test filename with multiple dots"""
|
||||
assert sanitize_filename("file.name.with.dots.tar.gz") == "file.name.with.dots.tar.gz"
|
||||
# Only the last part is used for extension in length limit
|
||||
|
||||
def test_sanitize_no_extension(self):
|
||||
"""Test filename without extension"""
|
||||
assert sanitize_filename("README") == "README"
|
||||
assert sanitize_filename("file\\name") == "file_name"
|
||||
|
||||
def test_sanitize_custom_max_length(self):
|
||||
"""Test custom max length parameter"""
|
||||
filename = "very_long_filename_here.txt"
|
||||
result = sanitize_filename(filename, max_length=10)
|
||||
assert len(result) <= 10
|
||||
# Truncates name but keeps extension
|
||||
assert result.endswith(".txt")
|
||||
|
||||
def test_sanitize_special_cases(self):
|
||||
"""Test various special cases"""
|
||||
# CON, PRN, AUX etc (Windows reserved names) - not handled currently
|
||||
# but we document behavior
|
||||
assert sanitize_filename("CON.txt") == "CON.txt"
|
||||
|
||||
# Filenames with spaces
|
||||
assert sanitize_filename("my video file.mp4") == "my video file.mp4"
|
||||
|
||||
# Mixed case
|
||||
assert sanitize_filename("ViDeO.Mp4") == "ViDeO.Mp4"
|
||||
|
||||
|
||||
class TestIsSafeFilename:
|
||||
"""Tests for is_safe_filename function"""
|
||||
|
||||
def test_safe_filenames(self):
|
||||
"""Test that safe filenames return True"""
|
||||
assert is_safe_filename("file.txt") is True
|
||||
assert is_safe_filename("my_video.mp4") is True
|
||||
assert is_safe_filename("document.pdf") is True
|
||||
assert is_safe_filename("archive.tar.gz") is True
|
||||
assert is_safe_filename("README") is True
|
||||
assert is_safe_filename("file with spaces.txt") is True
|
||||
assert is_safe_filename("file-with-dashes.txt") is True
|
||||
assert is_safe_filename("file_with_underscores.txt") is True
|
||||
|
||||
def test_unsafe_path_traversal(self):
|
||||
"""Test that path traversal attempts return False"""
|
||||
assert is_safe_filename("../etc/passwd") is False
|
||||
assert is_safe_filename("../../secret") is False
|
||||
assert is_safe_filename("../../../file.txt") is False
|
||||
assert is_safe_filename("....\\....\\file.txt") is False
|
||||
|
||||
def test_unsafe_absolute_paths(self):
|
||||
"""Test that absolute paths return False"""
|
||||
assert is_safe_filename("/etc/passwd") is False
|
||||
assert is_safe_filename("/var/log/file.txt") is False
|
||||
assert is_safe_filename("\\windows\\system32") is False
|
||||
assert is_safe_filename("\\\\network\\share") is False
|
||||
|
||||
def test_unsafe_current_directory(self):
|
||||
"""Test that current directory references return False"""
|
||||
assert is_safe_filename("./file.txt") is False
|
||||
assert is_safe_filename(".hidden") is False # Leading dot
|
||||
assert is_safe_filename("././file.txt") is False
|
||||
|
||||
def test_unsafe_windows_drives(self):
|
||||
"""Test that Windows drive letters return False"""
|
||||
assert is_safe_filename("C:\\file.txt") is False
|
||||
assert is_safe_filename("D:\\data\\file.txt") is False
|
||||
assert is_safe_filename("E:/file.txt") is False
|
||||
assert is_safe_filename("c:file.txt") is False
|
||||
|
||||
def test_empty_filename(self):
|
||||
"""Test that empty filename returns False"""
|
||||
assert is_safe_filename("") is False
|
||||
assert is_safe_filename(" ") is False
|
||||
|
||||
def test_mixed_slashes(self):
|
||||
"""Test mixed forward and backward slashes"""
|
||||
assert is_safe_filename("folder\\file/name.txt") is False
|
||||
assert is_safe_filename("folder/sub\\file.txt") is False
|
||||
|
||||
def test_unicode_safe(self):
|
||||
"""Test unicode filenames are considered safe if no path traversal"""
|
||||
assert is_safe_filename("ファイル.txt") is True
|
||||
assert is_safe_filename("café.txt") is True
|
||||
assert is_safe_filename("файл.txt") is True
|
||||
|
||||
def test_edge_cases(self):
|
||||
"""Test edge cases"""
|
||||
# Just a dot
|
||||
assert is_safe_filename(".") is False
|
||||
|
||||
# Multiple dots
|
||||
assert is_safe_filename("...") is False
|
||||
|
||||
# Dots in middle are OK
|
||||
assert is_safe_filename("file.name.txt") is True
|
||||
|
||||
# Slash at end
|
||||
assert is_safe_filename("file.txt/") is False
|
||||
|
||||
# Backslash at end
|
||||
assert is_safe_filename("file.txt\\") is False
|
||||
|
||||
# Spaces only
|
||||
assert is_safe_filename(" ") is False
|
||||
|
||||
|
||||
class TestUtilityIntegration:
|
||||
"""Integration tests for utility functions working together"""
|
||||
|
||||
def test_sanitize_then_is_safe(self):
|
||||
"""Test that sanitized filenames are always safe"""
|
||||
unsafe_filenames = [
|
||||
"../../../etc/passwd",
|
||||
"/absolute/path/file.txt",
|
||||
"C:\\windows\\file.txt",
|
||||
"./local/file.txt",
|
||||
".hidden",
|
||||
"file\\with:bad*chars?.txt",
|
||||
]
|
||||
|
||||
for filename in unsafe_filenames:
|
||||
sanitized = sanitize_filename(filename)
|
||||
assert is_safe_filename(sanitized), f"Sanitized '{filename}' -> '{sanitized}' is not safe"
|
||||
|
||||
def test_roundtrip_safe_filenames(self):
|
||||
"""Test that safe filenames remain unchanged"""
|
||||
safe_filenames = [
|
||||
"file.txt",
|
||||
"my_video.mp4",
|
||||
"document.pdf",
|
||||
"archive.tar.gz",
|
||||
"README",
|
||||
"file with spaces.txt",
|
||||
]
|
||||
|
||||
for filename in safe_filenames:
|
||||
sanitized = sanitize_filename(filename)
|
||||
assert sanitized == filename, f"Safe filename '{filename}' was changed to '{sanitized}'"
|
||||
|
||||
def test_empty_string_handling(self):
|
||||
"""Test that empty string is handled consistently"""
|
||||
sanitized = sanitize_filename("")
|
||||
assert sanitized == "download"
|
||||
assert is_safe_filename(sanitized) is True
|
||||
Reference in New Issue
Block a user