Compare commits
8 Commits
067926e80d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f1d24fd24 | ||
|
|
e2da749e0e | ||
|
|
f2ec1335a9 | ||
|
|
0fc4475040 | ||
|
|
be76f0a610 | ||
|
|
337d46cbb5 | ||
|
|
b059aab28f | ||
|
|
acb2ec0654 |
@@ -9,7 +9,7 @@ RUN apt-get update && apt-get install -y \
|
|||||||
|
|
||||||
# Install uv for faster Python package management
|
# Install uv for faster Python package management
|
||||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
ENV PATH="/root/.cargo/bin:$PATH"
|
ENV PATH="/root/.local/bin:$PATH"
|
||||||
|
|
||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -30,4 +30,4 @@ RUN mkdir -p downloads
|
|||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|
||||||
# Default command (can be overridden in docker-compose)
|
# Default command (can be overridden in docker-compose)
|
||||||
CMD ["flask", "--app", "main", "run", "--host=0.0.0.0"]
|
CMD ["uv", "run", "flask", "--app", "main", "run", "--host=0.0.0.0"]
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ celery_app = Celery(
|
|||||||
"yottob",
|
"yottob",
|
||||||
broker=CELERY_BROKER_URL,
|
broker=CELERY_BROKER_URL,
|
||||||
backend=CELERY_RESULT_BACKEND,
|
backend=CELERY_RESULT_BACKEND,
|
||||||
include=["download_service"]
|
include=["download_service", "scheduled_tasks"]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Celery configuration
|
# Celery configuration
|
||||||
@@ -27,4 +27,21 @@ celery_app.conf.update(
|
|||||||
task_soft_time_limit=3300, # 55 minutes soft limit
|
task_soft_time_limit=3300, # 55 minutes soft limit
|
||||||
worker_prefetch_multiplier=1, # Process one task at a time
|
worker_prefetch_multiplier=1, # Process one task at a time
|
||||||
worker_max_tasks_per_child=50, # Restart worker after 50 tasks
|
worker_max_tasks_per_child=50, # Restart worker after 50 tasks
|
||||||
|
beat_schedule={
|
||||||
|
"cleanup-old-videos-daily": {
|
||||||
|
"task": "download_service.cleanup_old_videos",
|
||||||
|
"schedule": 86400.0, # Run every 24 hours (in seconds)
|
||||||
|
},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Scheduled tasks
|
||||||
|
from celery.schedules import crontab
|
||||||
|
|
||||||
|
celery_app.conf.beat_schedule = {
|
||||||
|
"check-latest-videos-midnight": {
|
||||||
|
"task": "scheduled_tasks.check_and_download_latest_videos",
|
||||||
|
"schedule": crontab(minute=0, hour=0), # Run at midnight UTC
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
version: '3.8'
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
@@ -32,14 +32,14 @@ services:
|
|||||||
app:
|
app:
|
||||||
build: .
|
build: .
|
||||||
container_name: yottob-app
|
container_name: yottob-app
|
||||||
command: flask --app main run --host=0.0.0.0 --port=5000
|
command: uv run flask --app main run --host=0.0.0.0 --port=5000
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://${POSTGRES_USER:-yottob}:${POSTGRES_PASSWORD:-yottob_password}@postgres:5432/${POSTGRES_DB:-yottob}
|
DATABASE_URL: postgresql://${POSTGRES_USER:-yottob}:${POSTGRES_PASSWORD:-yottob_password}@postgres:5432/${POSTGRES_DB:-yottob}
|
||||||
CELERY_BROKER_URL: redis://redis:6379/0
|
CELERY_BROKER_URL: redis://redis:6379/0
|
||||||
CELERY_RESULT_BACKEND: redis://redis:6379/0
|
CELERY_RESULT_BACKEND: redis://redis:6379/0
|
||||||
FLASK_ENV: ${FLASK_ENV:-development}
|
FLASK_ENV: ${FLASK_ENV:-development}
|
||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "5123:5000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./downloads:/app/downloads
|
- ./downloads:/app/downloads
|
||||||
- ./:/app
|
- ./:/app
|
||||||
@@ -53,7 +53,7 @@ services:
|
|||||||
celery:
|
celery:
|
||||||
build: .
|
build: .
|
||||||
container_name: yottob-celery
|
container_name: yottob-celery
|
||||||
command: celery -A celery_app worker --loglevel=info
|
command: uv run celery -A celery_app worker --loglevel=info
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://${POSTGRES_USER:-yottob}:${POSTGRES_PASSWORD:-yottob_password}@postgres:5432/${POSTGRES_DB:-yottob}
|
DATABASE_URL: postgresql://${POSTGRES_USER:-yottob}:${POSTGRES_PASSWORD:-yottob_password}@postgres:5432/${POSTGRES_DB:-yottob}
|
||||||
CELERY_BROKER_URL: redis://redis:6379/0
|
CELERY_BROKER_URL: redis://redis:6379/0
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ def download_video(self, video_id: int) -> dict:
|
|||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Extract video ID from YouTube URL
|
# Get video URL from database
|
||||||
youtube_url = video.link
|
youtube_url = video.video_url
|
||||||
|
|
||||||
# Configure yt-dlp options for MP4 output
|
# Configure yt-dlp options for MP4 output
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
@@ -163,3 +163,112 @@ def download_videos_batch(video_ids: list[int]) -> dict:
|
|||||||
"total_queued": len(results),
|
"total_queued": len(results),
|
||||||
"tasks": results
|
"tasks": results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(base=DatabaseTask, bind=True)
|
||||||
|
def delete_video_file(self, video_id: int) -> dict:
|
||||||
|
"""Delete a downloaded video file and reset its download status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
video_id: Database ID of the VideoEntry
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with deletion result information
|
||||||
|
"""
|
||||||
|
session = self.session
|
||||||
|
|
||||||
|
# Get video entry from database
|
||||||
|
video = session.query(VideoEntry).filter_by(id=video_id).first()
|
||||||
|
if not video:
|
||||||
|
return {"error": f"Video ID {video_id} not found"}
|
||||||
|
|
||||||
|
# Check if video has a download path
|
||||||
|
if not video.download_path:
|
||||||
|
return {"error": "Video has no download path", "video_id": video_id}
|
||||||
|
|
||||||
|
# Delete the file if it exists
|
||||||
|
deleted = False
|
||||||
|
if os.path.exists(video.download_path):
|
||||||
|
try:
|
||||||
|
os.remove(video.download_path)
|
||||||
|
deleted = True
|
||||||
|
except OSError as e:
|
||||||
|
return {
|
||||||
|
"error": f"Failed to delete file: {str(e)}",
|
||||||
|
"video_id": video_id,
|
||||||
|
"path": video.download_path
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reset download status and metadata
|
||||||
|
video.download_status = DownloadStatus.PENDING
|
||||||
|
video.download_path = None
|
||||||
|
video.download_completed_at = None
|
||||||
|
video.file_size = None
|
||||||
|
video.download_error = None
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"video_id": video_id,
|
||||||
|
"status": "deleted" if deleted else "reset",
|
||||||
|
"message": "File deleted and status reset" if deleted else "Status reset (file not found)"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(base=DatabaseTask, bind=True)
|
||||||
|
def cleanup_old_videos(self) -> dict:
|
||||||
|
"""Clean up videos older than 7 days.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with cleanup results
|
||||||
|
"""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
session = self.session
|
||||||
|
|
||||||
|
# Calculate cutoff date (7 days ago)
|
||||||
|
cutoff_date = datetime.utcnow() - timedelta(days=7)
|
||||||
|
|
||||||
|
# Query videos that are completed and older than 7 days
|
||||||
|
old_videos = session.query(VideoEntry).filter(
|
||||||
|
VideoEntry.download_status == DownloadStatus.COMPLETED,
|
||||||
|
VideoEntry.download_completed_at < cutoff_date
|
||||||
|
).all()
|
||||||
|
|
||||||
|
deleted_count = 0
|
||||||
|
failed_count = 0
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for video in old_videos:
|
||||||
|
if video.download_path and os.path.exists(video.download_path):
|
||||||
|
try:
|
||||||
|
os.remove(video.download_path)
|
||||||
|
# Reset download status
|
||||||
|
video.download_status = DownloadStatus.PENDING
|
||||||
|
video.download_path = None
|
||||||
|
video.download_completed_at = None
|
||||||
|
video.file_size = None
|
||||||
|
video.download_error = None
|
||||||
|
deleted_count += 1
|
||||||
|
results.append({
|
||||||
|
"video_id": video.id,
|
||||||
|
"title": video.title,
|
||||||
|
"status": "deleted"
|
||||||
|
})
|
||||||
|
except OSError as e:
|
||||||
|
failed_count += 1
|
||||||
|
results.append({
|
||||||
|
"video_id": video.id,
|
||||||
|
"title": video.title,
|
||||||
|
"status": "failed",
|
||||||
|
"error": str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_processed": len(old_videos),
|
||||||
|
"deleted_count": deleted_count,
|
||||||
|
"failed_count": failed_count,
|
||||||
|
"cutoff_date": cutoff_date.isoformat(),
|
||||||
|
"results": results
|
||||||
|
}
|
||||||
|
|||||||
133
feed_parser.py
133
feed_parser.py
@@ -8,6 +8,7 @@ from datetime import datetime
|
|||||||
import feedparser
|
import feedparser
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
import re
|
import re
|
||||||
|
import yt_dlp
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
@@ -70,7 +71,7 @@ class YouTubeFeedParser:
|
|||||||
|
|
||||||
entries = []
|
entries = []
|
||||||
for entry in feed.entries:
|
for entry in feed.entries:
|
||||||
if filter_shorts and "shorts" in entry.link:
|
if filter_shorts and "shorts" in entry.link.lower():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Extract video ID from URL
|
# Extract video ID from URL
|
||||||
@@ -198,3 +199,133 @@ class YouTubeFeedParser:
|
|||||||
|
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
return channel
|
return channel
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_single_video(video_url: str) -> Optional[Dict]:
|
||||||
|
"""Fetch metadata for a single YouTube video using yt-dlp.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
video_url: YouTube video URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with video metadata or None if fetch fails
|
||||||
|
"""
|
||||||
|
# Check if URL contains "shorts" - reject shorts
|
||||||
|
if "shorts" in video_url.lower():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
ydl_opts = {
|
||||||
|
'quiet': True,
|
||||||
|
'no_warnings': True,
|
||||||
|
'extract_flat': False,
|
||||||
|
}
|
||||||
|
|
||||||
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
info = ydl.extract_info(video_url, download=False)
|
||||||
|
|
||||||
|
if not info:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Extract video ID from URL
|
||||||
|
video_id = info.get('id')
|
||||||
|
if not video_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get channel info
|
||||||
|
channel_id = info.get('channel_id')
|
||||||
|
channel_name = info.get('channel') or info.get('uploader')
|
||||||
|
channel_url = info.get('channel_url') or f"https://www.youtube.com/channel/{channel_id}"
|
||||||
|
|
||||||
|
# Get thumbnail - prefer maxresdefault, fall back to other qualities
|
||||||
|
thumbnail_url = None
|
||||||
|
if info.get('thumbnails'):
|
||||||
|
# Get highest quality thumbnail
|
||||||
|
thumbnail_url = info['thumbnails'][-1].get('url')
|
||||||
|
elif info.get('thumbnail'):
|
||||||
|
thumbnail_url = info.get('thumbnail')
|
||||||
|
|
||||||
|
# Parse upload date
|
||||||
|
upload_date_str = info.get('upload_date')
|
||||||
|
if upload_date_str:
|
||||||
|
# Format: YYYYMMDD
|
||||||
|
published_at = datetime.strptime(upload_date_str, '%Y%m%d')
|
||||||
|
else:
|
||||||
|
published_at = datetime.utcnow()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'video_id': video_id,
|
||||||
|
'title': info.get('title'),
|
||||||
|
'video_url': f"https://www.youtube.com/watch?v={video_id}",
|
||||||
|
'description': info.get('description'),
|
||||||
|
'thumbnail_url': thumbnail_url,
|
||||||
|
'published_at': published_at,
|
||||||
|
'channel_id': channel_id,
|
||||||
|
'channel_name': channel_name,
|
||||||
|
'channel_url': channel_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error fetching video metadata: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def save_single_video_to_db(db_session: Session, video_data: Dict, user_id: int) -> VideoEntry:
|
||||||
|
"""Save a single video to the database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_session: SQLAlchemy database session
|
||||||
|
video_data: Dictionary containing video metadata (from fetch_single_video)
|
||||||
|
user_id: ID of the user adding this video
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The VideoEntry model instance
|
||||||
|
|
||||||
|
This method:
|
||||||
|
- Creates channel if it doesn't exist for this user
|
||||||
|
- Creates video entry if it doesn't exist
|
||||||
|
- Returns existing video if already in database
|
||||||
|
"""
|
||||||
|
# Get or create channel for this user
|
||||||
|
channel = db_session.query(Channel).filter_by(
|
||||||
|
user_id=user_id,
|
||||||
|
channel_id=video_data['channel_id']
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not channel:
|
||||||
|
# Create new channel
|
||||||
|
channel = Channel(
|
||||||
|
user_id=user_id,
|
||||||
|
channel_id=video_data['channel_id'],
|
||||||
|
title=video_data['channel_name'],
|
||||||
|
link=video_data['channel_url'],
|
||||||
|
rss_url=f"https://www.youtube.com/feeds/videos.xml?channel_id={video_data['channel_id']}",
|
||||||
|
last_fetched_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db_session.add(channel)
|
||||||
|
db_session.flush() # Get the channel ID
|
||||||
|
|
||||||
|
# Check if video already exists for this channel
|
||||||
|
existing_video = db_session.query(VideoEntry).filter_by(
|
||||||
|
channel_id=channel.id,
|
||||||
|
video_id=video_data['video_id']
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_video:
|
||||||
|
return existing_video
|
||||||
|
|
||||||
|
# Create new video entry
|
||||||
|
video = VideoEntry(
|
||||||
|
channel_id=channel.id,
|
||||||
|
video_id=video_data['video_id'],
|
||||||
|
title=video_data['title'],
|
||||||
|
video_url=video_data['video_url'],
|
||||||
|
thumbnail_url=video_data.get('thumbnail_url'),
|
||||||
|
description=video_data.get('description'),
|
||||||
|
published_at=video_data['published_at'],
|
||||||
|
created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db_session.add(video)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
return video
|
||||||
|
|||||||
211
main.py
211
main.py
@@ -3,10 +3,10 @@
|
|||||||
import os
|
import os
|
||||||
from flask import Flask, render_template, request, jsonify, redirect, url_for, flash, send_file
|
from flask import Flask, render_template, request, jsonify, redirect, url_for, flash, send_file
|
||||||
from flask_login import LoginManager, login_user, logout_user, login_required, current_user
|
from flask_login import LoginManager, login_user, logout_user, login_required, current_user
|
||||||
from feed_parser import YouTubeFeedParser
|
from feed_parser import YouTubeFeedParser, fetch_single_video, save_single_video_to_db
|
||||||
from database import init_db, get_db_session
|
from database import init_db, get_db_session
|
||||||
from models import Channel, VideoEntry, DownloadStatus, User
|
from models import Channel, VideoEntry, DownloadStatus, User
|
||||||
from download_service import download_video, download_videos_batch
|
from download_service import download_video, download_videos_batch, delete_video_file
|
||||||
from sqlalchemy import desc
|
from sqlalchemy import desc
|
||||||
|
|
||||||
|
|
||||||
@@ -24,15 +24,12 @@ login_manager.login_message_category = "info"
|
|||||||
@login_manager.user_loader
|
@login_manager.user_loader
|
||||||
def load_user(user_id):
|
def load_user(user_id):
|
||||||
"""Load user by ID for Flask-Login."""
|
"""Load user by ID for Flask-Login."""
|
||||||
session = next(get_db_session())
|
with get_db_session() as session:
|
||||||
try:
|
|
||||||
user = session.query(User).get(int(user_id))
|
user = session.query(User).get(int(user_id))
|
||||||
if user:
|
if user:
|
||||||
# Expire the user from this session so it can be used in request context
|
# Expire the user from this session so it can be used in request context
|
||||||
session.expunge(user)
|
session.expunge(user)
|
||||||
return user
|
return user
|
||||||
finally:
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
# Default channel ID for demonstration
|
# Default channel ID for demonstration
|
||||||
DEFAULT_CHANNEL_ID = "UCtTWOND3uyl4tVc_FarDmpw"
|
DEFAULT_CHANNEL_ID = "UCtTWOND3uyl4tVc_FarDmpw"
|
||||||
@@ -157,11 +154,22 @@ def logout():
|
|||||||
def index():
|
def index():
|
||||||
"""Render the dashboard with all videos sorted by date."""
|
"""Render the dashboard with all videos sorted by date."""
|
||||||
try:
|
try:
|
||||||
|
channel_id = request.args.get("channel")
|
||||||
|
|
||||||
with get_db_session() as session:
|
with get_db_session() as session:
|
||||||
# Query videos for current user's channels, sorted by published date (newest first)
|
# Base query for current user's videos
|
||||||
videos = session.query(VideoEntry).join(Channel).filter(
|
query = session.query(VideoEntry).join(Channel).filter(
|
||||||
Channel.user_id == current_user.id
|
Channel.user_id == current_user.id
|
||||||
).order_by(desc(VideoEntry.published_at)).all()
|
)
|
||||||
|
|
||||||
|
# If channel filter is specified, show ALL videos from that channel
|
||||||
|
if channel_id:
|
||||||
|
query = query.filter(Channel.id == int(channel_id))
|
||||||
|
else:
|
||||||
|
# Otherwise, only show completed videos
|
||||||
|
query = query.filter(VideoEntry.download_status == DownloadStatus.COMPLETED)
|
||||||
|
|
||||||
|
videos = query.order_by(desc(VideoEntry.published_at)).all()
|
||||||
|
|
||||||
return render_template("dashboard.html", videos=videos)
|
return render_template("dashboard.html", videos=videos)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -184,6 +192,32 @@ def channels_page():
|
|||||||
return render_template("channels.html", channels=[])
|
return render_template("channels.html", channels=[])
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/downloads", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def downloads_page():
|
||||||
|
"""Render the downloads page showing all download jobs."""
|
||||||
|
try:
|
||||||
|
with get_db_session() as session:
|
||||||
|
# Get all videos for current user's channels with download activity
|
||||||
|
# Order by: downloading first, then pending, then failed, then completed
|
||||||
|
# Within each status, order by download_started_at or created_at
|
||||||
|
videos = session.query(VideoEntry).join(Channel).filter(
|
||||||
|
Channel.user_id == current_user.id
|
||||||
|
).order_by(
|
||||||
|
# Custom sort: downloading=0, pending=1, failed=2, completed=3
|
||||||
|
desc(VideoEntry.download_status == DownloadStatus.DOWNLOADING),
|
||||||
|
desc(VideoEntry.download_status == DownloadStatus.PENDING),
|
||||||
|
desc(VideoEntry.download_status == DownloadStatus.FAILED),
|
||||||
|
desc(VideoEntry.download_started_at),
|
||||||
|
desc(VideoEntry.created_at)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
return render_template("downloads.html", videos=videos)
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"Error loading downloads: {str(e)}", "error")
|
||||||
|
return render_template("downloads.html", videos=[])
|
||||||
|
|
||||||
|
|
||||||
@app.route("/add-channel", methods=["GET", "POST"])
|
@app.route("/add-channel", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def add_channel_page():
|
def add_channel_page():
|
||||||
@@ -192,34 +226,33 @@ def add_channel_page():
|
|||||||
return render_template("add_channel.html")
|
return render_template("add_channel.html")
|
||||||
|
|
||||||
# Handle POST - add new channel
|
# Handle POST - add new channel
|
||||||
rss_url = request.form.get("rss_url")
|
channel_id = request.form.get("channel_id")
|
||||||
if not rss_url:
|
if not channel_id:
|
||||||
flash("RSS URL is required", "error")
|
flash("Channel ID is required", "error")
|
||||||
|
return redirect(url_for("add_channel_page"))
|
||||||
|
|
||||||
|
# Validate channel ID format (should start with UC or UU)
|
||||||
|
channel_id = channel_id.strip()
|
||||||
|
if not channel_id.startswith(("UC", "UU")):
|
||||||
|
flash("Invalid channel ID format. Channel IDs should start with UC or UU.", "error")
|
||||||
return redirect(url_for("add_channel_page"))
|
return redirect(url_for("add_channel_page"))
|
||||||
|
|
||||||
# Extract channel_id from RSS URL
|
|
||||||
# Format: https://www.youtube.com/feeds/videos.xml?channel_id=CHANNEL_ID
|
|
||||||
try:
|
try:
|
||||||
if "channel_id=" in rss_url:
|
|
||||||
channel_id = rss_url.split("channel_id=")[1].split("&")[0]
|
|
||||||
else:
|
|
||||||
flash("Invalid RSS URL format. Must contain channel_id parameter.", "error")
|
|
||||||
return redirect(url_for("add_channel_page"))
|
|
||||||
|
|
||||||
# Fetch feed using parser
|
# Fetch feed using parser
|
||||||
parser = YouTubeFeedParser(channel_id)
|
parser = YouTubeFeedParser(channel_id)
|
||||||
result = parser.fetch_feed()
|
result = parser.fetch_feed()
|
||||||
|
|
||||||
if result is None:
|
if result is None:
|
||||||
flash("Failed to fetch feed from YouTube", "error")
|
flash("Failed to fetch feed from YouTube. Please check the channel ID.", "error")
|
||||||
return redirect(url_for("add_channel_page"))
|
return redirect(url_for("add_channel_page"))
|
||||||
|
|
||||||
# Save to database with current user
|
# Save to database with current user
|
||||||
with get_db_session() as session:
|
with get_db_session() as session:
|
||||||
channel = parser.save_to_db(session, result, current_user.id)
|
channel = parser.save_to_db(session, result, current_user.id)
|
||||||
|
channel_title = channel.title
|
||||||
video_count = len(result["entries"])
|
video_count = len(result["entries"])
|
||||||
|
|
||||||
flash(f"Successfully subscribed to {channel.title}! Added {video_count} videos.", "success")
|
flash(f"Successfully subscribed to {channel_title}! Added {video_count} videos.", "success")
|
||||||
return redirect(url_for("channels_page"))
|
return redirect(url_for("channels_page"))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -227,6 +260,51 @@ def add_channel_page():
|
|||||||
return redirect(url_for("add_channel_page"))
|
return redirect(url_for("add_channel_page"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/add-video", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
def add_video_page():
|
||||||
|
"""Render the add video page and handle video submission."""
|
||||||
|
if request.method == "GET":
|
||||||
|
return render_template("add_video.html")
|
||||||
|
|
||||||
|
# Handle POST - add new video
|
||||||
|
video_url = request.form.get("video_url")
|
||||||
|
if not video_url:
|
||||||
|
flash("Video URL is required", "error")
|
||||||
|
return redirect(url_for("add_video_page"))
|
||||||
|
|
||||||
|
# Validate it's a YouTube URL
|
||||||
|
video_url = video_url.strip()
|
||||||
|
if not ("youtube.com/watch" in video_url or "youtu.be/" in video_url):
|
||||||
|
flash("Invalid YouTube URL. Please provide a valid YouTube video link.", "error")
|
||||||
|
return redirect(url_for("add_video_page"))
|
||||||
|
|
||||||
|
# Check if it's a Shorts video
|
||||||
|
if "shorts" in video_url.lower():
|
||||||
|
flash("YouTube Shorts are not supported.", "error")
|
||||||
|
return redirect(url_for("add_video_page"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Fetch video metadata
|
||||||
|
video_data = fetch_single_video(video_url)
|
||||||
|
|
||||||
|
if video_data is None:
|
||||||
|
flash("Failed to fetch video from YouTube. Please check the URL.", "error")
|
||||||
|
return redirect(url_for("add_video_page"))
|
||||||
|
|
||||||
|
# Save to database with current user
|
||||||
|
with get_db_session() as session:
|
||||||
|
video = save_single_video_to_db(session, video_data, current_user.id)
|
||||||
|
video_title = video.title
|
||||||
|
|
||||||
|
flash(f"Successfully added video: {video_title}!", "success")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"Error adding video: {str(e)}", "error")
|
||||||
|
return redirect(url_for("add_video_page"))
|
||||||
|
|
||||||
|
|
||||||
@app.route("/watch/<int:video_id>", methods=["GET"])
|
@app.route("/watch/<int:video_id>", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
def watch_video(video_id: int):
|
def watch_video(video_id: int):
|
||||||
@@ -255,18 +333,16 @@ def get_feed():
|
|||||||
|
|
||||||
Query parameters:
|
Query parameters:
|
||||||
channel_id: YouTube channel ID (optional, uses default if not provided)
|
channel_id: YouTube channel ID (optional, uses default if not provided)
|
||||||
filter_shorts: Whether to filter out Shorts (default: true)
|
|
||||||
save: Whether to save to database (default: true)
|
save: Whether to save to database (default: true)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JSON response with feed data or error message
|
JSON response with feed data or error message
|
||||||
"""
|
"""
|
||||||
channel_id = request.args.get("channel_id", DEFAULT_CHANNEL_ID)
|
channel_id = request.args.get("channel_id", DEFAULT_CHANNEL_ID)
|
||||||
filter_shorts = request.args.get("filter_shorts", "true").lower() == "true"
|
|
||||||
save_to_db = request.args.get("save", "true").lower() == "true"
|
save_to_db = request.args.get("save", "true").lower() == "true"
|
||||||
|
|
||||||
parser = YouTubeFeedParser(channel_id)
|
parser = YouTubeFeedParser(channel_id)
|
||||||
result = parser.fetch_feed(filter_shorts=filter_shorts)
|
result = parser.fetch_feed(filter_shorts=True)
|
||||||
|
|
||||||
if result is None:
|
if result is None:
|
||||||
return jsonify({"error": "Failed to fetch feed"}), 500
|
return jsonify({"error": "Failed to fetch feed"}), 500
|
||||||
@@ -542,6 +618,49 @@ def refresh_channel_videos(channel_id: int):
|
|||||||
return jsonify({"status": "error", "message": f"Failed to refresh channel: {str(e)}"}), 500
|
return jsonify({"status": "error", "message": f"Failed to refresh channel: {str(e)}"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/channels/<int:channel_id>", methods=["DELETE"])
|
||||||
|
@login_required
|
||||||
|
def delete_channel(channel_id: int):
|
||||||
|
"""Delete a channel and all its associated data (videos and files).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
channel_id: Database ID of the Channel
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON response indicating success or failure
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with get_db_session() as session:
|
||||||
|
# Verify ownership and get channel with videos
|
||||||
|
channel = session.query(Channel).filter_by(
|
||||||
|
id=channel_id,
|
||||||
|
user_id=current_user.id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not channel:
|
||||||
|
return jsonify({"status": "error", "message": "Channel not found"}), 404
|
||||||
|
|
||||||
|
# Delete downloaded files
|
||||||
|
deleted_files_count = 0
|
||||||
|
for video in channel.video_entries:
|
||||||
|
if video.download_path and os.path.exists(video.download_path):
|
||||||
|
try:
|
||||||
|
os.remove(video.download_path)
|
||||||
|
deleted_files_count += 1
|
||||||
|
except OSError as e:
|
||||||
|
print(f"Error deleting file {video.download_path}: {e}")
|
||||||
|
|
||||||
|
# Delete channel (cascade will handle video entries in DB)
|
||||||
|
session.delete(channel)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"status": "success",
|
||||||
|
"message": f"Channel deleted successfully. Removed {deleted_files_count} downloaded files."
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"status": "error", "message": f"Failed to delete channel: {str(e)}"}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/video/stream/<int:video_id>", methods=["GET"])
|
@app.route("/api/video/stream/<int:video_id>", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
def stream_video(video_id: int):
|
def stream_video(video_id: int):
|
||||||
@@ -587,7 +706,49 @@ def stream_video(video_id: int):
|
|||||||
return jsonify({"error": f"Failed to stream video: {str(e)}"}), 500
|
return jsonify({"error": f"Failed to stream video: {str(e)}"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/videos/<int:video_id>/file", methods=["DELETE"])
|
||||||
|
@login_required
|
||||||
|
def delete_video(video_id: int):
|
||||||
|
"""Delete a downloaded video file from disk.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
video_id: Database ID of the VideoEntry
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON response indicating success or failure
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with get_db_session() as session:
|
||||||
|
# Only allow deleting videos from user's own channels
|
||||||
|
video = session.query(VideoEntry).join(Channel).filter(
|
||||||
|
VideoEntry.id == video_id,
|
||||||
|
Channel.user_id == current_user.id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not video:
|
||||||
|
return jsonify({"status": "error", "message": "Video not found"}), 404
|
||||||
|
|
||||||
|
if video.download_status != DownloadStatus.COMPLETED:
|
||||||
|
return jsonify({
|
||||||
|
"status": "error",
|
||||||
|
"message": "Video is not downloaded"
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Queue deletion task
|
||||||
|
task = delete_video_file.delay(video_id)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"status": "success",
|
||||||
|
"video_id": video_id,
|
||||||
|
"task_id": task.id,
|
||||||
|
"message": "Video deletion queued"
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"status": "error", "message": f"Failed to delete video: {str(e)}"}), 500
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|
||||||
"""CLI entry point for testing feed parser."""
|
"""CLI entry point for testing feed parser."""
|
||||||
parser = YouTubeFeedParser(DEFAULT_CHANNEL_ID)
|
parser = YouTubeFeedParser(DEFAULT_CHANNEL_ID)
|
||||||
result = parser.fetch_feed()
|
result = parser.fetch_feed()
|
||||||
|
|||||||
54
scheduled_tasks.py
Normal file
54
scheduled_tasks.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""Scheduled tasks for Yottob."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from celery.schedules import crontab
|
||||||
|
from sqlalchemy import desc
|
||||||
|
|
||||||
|
from celery_app import celery_app
|
||||||
|
from database import SessionLocal
|
||||||
|
from models import Channel, VideoEntry, DownloadStatus
|
||||||
|
from feed_parser import YouTubeFeedParser
|
||||||
|
from download_service import download_video
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task
|
||||||
|
def check_and_download_latest_videos():
|
||||||
|
"""Check all channels for new videos and download the latest one if pending."""
|
||||||
|
session = SessionLocal()
|
||||||
|
try:
|
||||||
|
channels = session.query(Channel).all()
|
||||||
|
logger.info(f"Checking {len(channels)} channels for new videos")
|
||||||
|
|
||||||
|
for channel in channels:
|
||||||
|
try:
|
||||||
|
# Fetch latest feed
|
||||||
|
parser = YouTubeFeedParser(channel.channel_id)
|
||||||
|
feed_data = parser.fetch_feed()
|
||||||
|
|
||||||
|
if not feed_data:
|
||||||
|
logger.warning(f"Failed to fetch feed for channel {channel.title} ({channel.channel_id})")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Save to DB (updates channel and adds new videos)
|
||||||
|
parser.save_to_db(session, feed_data, channel.user_id)
|
||||||
|
|
||||||
|
# Get the latest video for this channel
|
||||||
|
latest_video = session.query(VideoEntry)\
|
||||||
|
.filter_by(channel_id=channel.id)\
|
||||||
|
.order_by(desc(VideoEntry.published_at))\
|
||||||
|
.first()
|
||||||
|
|
||||||
|
if latest_video and latest_video.download_status == DownloadStatus.PENDING:
|
||||||
|
logger.info(f"Queueing download for latest video: {latest_video.title} ({latest_video.video_id})")
|
||||||
|
download_video.delay(latest_video.id)
|
||||||
|
elif latest_video:
|
||||||
|
logger.info(f"Latest video {latest_video.title} status is {latest_video.download_status.value}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing channel {channel.title}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
194
static/style.css
194
static/style.css
@@ -6,16 +6,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--primary-color: #ff0000;
|
--primary-color: #000000;
|
||||||
--secondary-color: #282828;
|
--secondary-color: #f5f5f5;
|
||||||
--background-color: #0f0f0f;
|
--background-color: #ffffff;
|
||||||
--card-background: #1f1f1f;
|
--card-background: #fafafa;
|
||||||
--text-primary: #ffffff;
|
--text-primary: #000000;
|
||||||
--text-secondary: #aaaaaa;
|
--text-secondary: #666666;
|
||||||
--border-color: #303030;
|
--border-color: #e0e0e0;
|
||||||
--success-color: #00aa00;
|
--success-color: #000000;
|
||||||
--error-color: #cc0000;
|
--error-color: #000000;
|
||||||
--info-color: #0066cc;
|
--info-color: #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -45,7 +45,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.logo a {
|
.logo a {
|
||||||
color: var(--primary-color);
|
color: #000000;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
@@ -55,6 +55,13 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-menu a {
|
.nav-menu a {
|
||||||
@@ -63,6 +70,7 @@ body {
|
|||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s;
|
||||||
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-menu a:hover,
|
.nav-menu a:hover,
|
||||||
@@ -98,18 +106,21 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.alert-success {
|
.alert-success {
|
||||||
background-color: rgba(0, 170, 0, 0.1);
|
background-color: #f5f5f5;
|
||||||
border-color: var(--success-color);
|
border-color: var(--success-color);
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-error {
|
.alert-error {
|
||||||
background-color: rgba(204, 0, 0, 0.1);
|
background-color: #f5f5f5;
|
||||||
border-color: var(--error-color);
|
border-color: var(--error-color);
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-info {
|
.alert-info {
|
||||||
background-color: rgba(0, 102, 204, 0.1);
|
background-color: #f5f5f5;
|
||||||
border-color: var(--info-color);
|
border-color: var(--info-color);
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dashboard */
|
/* Dashboard */
|
||||||
@@ -140,7 +151,7 @@ body {
|
|||||||
|
|
||||||
.video-card:hover {
|
.video-card:hover {
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-thumbnail {
|
.video-thumbnail {
|
||||||
@@ -188,7 +199,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.video-title a:hover {
|
.video-title a:hover {
|
||||||
color: var(--primary-color);
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-channel {
|
.video-channel {
|
||||||
@@ -217,17 +228,17 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.badge-success {
|
.badge-success {
|
||||||
background-color: var(--success-color);
|
background-color: #000000;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-info {
|
.badge-info {
|
||||||
background-color: var(--info-color);
|
background-color: #666666;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-error {
|
.badge-error {
|
||||||
background-color: var(--error-color);
|
background-color: #000000;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,24 +259,35 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background-color: var(--primary-color);
|
background-color: #000000;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background-color: var(--border-color);
|
background-color: #f5f5f5;
|
||||||
color: var(--text-primary);
|
color: #000000;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-link {
|
.btn-link {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: var(--primary-color);
|
color: #000000;
|
||||||
border: 1px solid var(--primary-color);
|
border: 1px solid #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: #000000;
|
||||||
|
color: white;
|
||||||
|
border: 1px solid #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background-color: #333333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-download {
|
.btn-download {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: var(--primary-color);
|
color: #000000;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -331,7 +353,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.channel-url a:hover {
|
.channel-url a:hover {
|
||||||
color: var(--primary-color);
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-meta {
|
.channel-meta {
|
||||||
@@ -346,6 +368,103 @@ body {
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Downloads Page */
|
||||||
|
.downloads-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-card {
|
||||||
|
background-color: var(--card-background);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 200px 1fr auto auto;
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-thumbnail {
|
||||||
|
width: 200px;
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-thumbnail img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-title a {
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-title a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-channel {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-status-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-size {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-error {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
max-width: 150px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
/* Form */
|
/* Form */
|
||||||
.form-container {
|
.form-container {
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
@@ -497,7 +616,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.channel-link:hover {
|
.channel-link:hover {
|
||||||
color: var(--primary-color);
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-stats {
|
.video-stats {
|
||||||
@@ -536,7 +655,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.back-link a:hover {
|
.back-link a:hover {
|
||||||
color: var(--primary-color);
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Auth Pages */
|
/* Auth Pages */
|
||||||
@@ -554,7 +673,8 @@ body {
|
|||||||
background-color: var(--card-background);
|
background-color: var(--card-background);
|
||||||
padding: 3rem;
|
padding: 3rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-container h2 {
|
.auth-container h2 {
|
||||||
@@ -605,8 +725,9 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.auth-footer a {
|
.auth-footer a {
|
||||||
color: var(--primary-color);
|
color: #000000;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-footer a:hover {
|
.auth-footer a:hover {
|
||||||
@@ -657,4 +778,17 @@ body {
|
|||||||
.video-stats {
|
.video-stats {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.download-card {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-thumbnail {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,18 +12,17 @@
|
|||||||
<div class="form-container">
|
<div class="form-container">
|
||||||
<form method="POST" action="/add-channel" class="channel-form">
|
<form method="POST" action="/add-channel" class="channel-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="rss_url">YouTube Channel RSS URL</label>
|
<label for="channel_id">YouTube Channel ID</label>
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="text"
|
||||||
id="rss_url"
|
id="channel_id"
|
||||||
name="rss_url"
|
name="channel_id"
|
||||||
placeholder="https://www.youtube.com/feeds/videos.xml?channel_id=..."
|
placeholder="UC_x5XG1OV2P6uZZ5FSM9Ttw"
|
||||||
required
|
required
|
||||||
class="form-input"
|
class="form-input"
|
||||||
>
|
>
|
||||||
<small class="form-help">
|
<small class="form-help">
|
||||||
Enter the RSS feed URL for the YouTube channel.
|
Enter the YouTube channel ID (starts with UC or UU).
|
||||||
Format: https://www.youtube.com/feeds/videos.xml?channel_id=CHANNEL_ID
|
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -35,23 +34,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="help-section">
|
<div class="help-section">
|
||||||
<h3>How to find a channel's RSS URL</h3>
|
<h3>How to find a channel ID</h3>
|
||||||
<ol>
|
<ol>
|
||||||
<li>Go to the YouTube channel page</li>
|
<li>Go to the YouTube channel page</li>
|
||||||
<li>Look at the URL in your browser - it will contain the channel ID</li>
|
<li>Look at the URL in your browser</li>
|
||||||
<li>The channel URL format is usually:
|
<li>If the URL is <code>youtube.com/channel/CHANNEL_ID</code>, copy the CHANNEL_ID part</li>
|
||||||
|
<li>If the URL is <code>youtube.com/@username</code>, you'll need to:
|
||||||
<ul>
|
<ul>
|
||||||
<li><code>youtube.com/channel/CHANNEL_ID</code> or</li>
|
<li>Right-click the page and select "View Page Source"</li>
|
||||||
<li><code>youtube.com/@username</code> (you'll need to find the channel ID from the page source)</li>
|
<li>Search for "channelId" or "browse_id"</li>
|
||||||
|
<li>Copy the ID that starts with "UC"</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>Use the format: <code>https://www.youtube.com/feeds/videos.xml?channel_id=CHANNEL_ID</code></li>
|
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<h4>Example</h4>
|
<h4>Example</h4>
|
||||||
<p>
|
<p>
|
||||||
For channel: <code>https://www.youtube.com/channel/UC_x5XG1OV2P6uZZ5FSM9Ttw</code><br>
|
For channel URL: <code>https://www.youtube.com/channel/UC_x5XG1OV2P6uZZ5FSM9Ttw</code><br>
|
||||||
RSS URL: <code>https://www.youtube.com/feeds/videos.xml?channel_id=UC_x5XG1OV2P6uZZ5FSM9Ttw</code>
|
Channel ID: <code>UC_x5XG1OV2P6uZZ5FSM9Ttw</code>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
55
templates/add_video.html
Normal file
55
templates/add_video.html
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Add Video - YottoB{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="add-video-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>Add Individual Video</h2>
|
||||||
|
<p>Add a specific YouTube video to your library</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-container">
|
||||||
|
<form method="POST" action="/add-video" class="channel-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="video_url">YouTube Video URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="video_url"
|
||||||
|
name="video_url"
|
||||||
|
placeholder="https://www.youtube.com/watch?v=VIDEO_ID"
|
||||||
|
required
|
||||||
|
class="form-input"
|
||||||
|
>
|
||||||
|
<small class="form-help">
|
||||||
|
Enter the full YouTube video URL.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Add Video</button>
|
||||||
|
<a href="/" class="btn btn-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-section">
|
||||||
|
<h3>How to add a video</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Go to the YouTube video you want to add</li>
|
||||||
|
<li>Copy the URL from your browser's address bar</li>
|
||||||
|
<li>Paste it into the form above</li>
|
||||||
|
<li>Click "Add Video"</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h4>Supported URL formats</h4>
|
||||||
|
<ul>
|
||||||
|
<li><code>https://www.youtube.com/watch?v=VIDEO_ID</code></li>
|
||||||
|
<li><code>https://youtu.be/VIDEO_ID</code></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>Note</h4>
|
||||||
|
<p>YouTube Shorts are not supported and will be rejected.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -3,18 +3,20 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}YottoB - YouTube Downloader{% endblock %}</title>
|
<title>{% block title %}Yottob - YouTube Downloader{% endblock %}</title>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="navbar">
|
<nav class="navbar">
|
||||||
<div class="nav-container">
|
<div class="nav-container">
|
||||||
<h1 class="logo"><a href="/">YottoB</a></h1>
|
<h1 class="logo"><a href="/">Yottob</a></h1>
|
||||||
<ul class="nav-menu">
|
<ul class="nav-menu">
|
||||||
{% if current_user.is_authenticated %}
|
{% if current_user.is_authenticated %}
|
||||||
<li><a href="/" class="{% if request.path == '/' %}active{% endif %}">Videos</a></li>
|
<li><a href="/" class="{% if request.path == '/' %}active{% endif %}">Videos</a></li>
|
||||||
<li><a href="/channels" class="{% if request.path == '/channels' %}active{% endif %}">Channels</a></li>
|
<li><a href="/channels" class="{% if request.path == '/channels' %}active{% endif %}">Channels</a></li>
|
||||||
|
<li><a href="/downloads" class="{% if request.path == '/downloads' %}active{% endif %}">Downloads</a></li>
|
||||||
<li><a href="/add-channel" class="{% if request.path == '/add-channel' %}active{% endif %}">Add Channel</a></li>
|
<li><a href="/add-channel" class="{% if request.path == '/add-channel' %}active{% endif %}">Add Channel</a></li>
|
||||||
|
<li><a href="/add-video" class="{% if request.path == '/add-video' %}active{% endif %}">Add Video</a></li>
|
||||||
<li class="nav-user">
|
<li class="nav-user">
|
||||||
<span>{{ current_user.username }}</span>
|
<span>{{ current_user.username }}</span>
|
||||||
<a href="{{ url_for('logout') }}">Logout</a>
|
<a href="{{ url_for('logout') }}">Logout</a>
|
||||||
@@ -40,7 +42,7 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<p>© 2025 YottoB - YouTube Video Downloader</p>
|
<p>© 2025 Yottob - YouTube Video Downloader</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
|
|||||||
@@ -26,9 +26,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="channel-actions">
|
<div class="channel-actions">
|
||||||
<button class="btn btn-secondary" onclick="refreshChannel({{ channel.id }})">
|
<button class="btn btn-secondary" onclick="refreshChannel({{ channel.id }}, event)">
|
||||||
Refresh Videos
|
Refresh Videos
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-danger" onclick="deleteChannel({{ channel.id }}, '{{ channel.title|replace("'", "\\'") }}', event)">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
<a href="/?channel={{ channel.id }}" class="btn btn-link">View Videos</a>
|
<a href="/?channel={{ channel.id }}" class="btn btn-link">View Videos</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -46,7 +49,7 @@
|
|||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
function refreshChannel(channelId) {
|
function refreshChannel(channelId, event) {
|
||||||
const button = event.target;
|
const button = event.target;
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
button.textContent = 'Refreshing...';
|
button.textContent = 'Refreshing...';
|
||||||
@@ -71,5 +74,31 @@
|
|||||||
button.textContent = 'Refresh Videos';
|
button.textContent = 'Refresh Videos';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deleteChannel(channelId, channelTitle, event) {
|
||||||
|
const button = event.target;
|
||||||
|
const originalText = button.textContent;
|
||||||
|
button.disabled = true;
|
||||||
|
button.textContent = 'Deleting...';
|
||||||
|
|
||||||
|
fetch(`/api/channels/${channelId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Failed to delete: ' + data.message);
|
||||||
|
button.disabled = false;
|
||||||
|
button.textContent = originalText;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
alert('Error: ' + error);
|
||||||
|
button.disabled = false;
|
||||||
|
button.textContent = originalText;
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
79
templates/downloads.html
Normal file
79
templates/downloads.html
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Downloads - YottoB{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="downloads-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>Download Jobs</h2>
|
||||||
|
<p>View and manage all video downloads</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if videos %}
|
||||||
|
<div class="downloads-list">
|
||||||
|
{% for video in videos %}
|
||||||
|
<div class="download-card">
|
||||||
|
<div class="download-thumbnail">
|
||||||
|
{% if video.thumbnail_url %}
|
||||||
|
<img src="{{ video.thumbnail_url }}" alt="{{ video.title }}">
|
||||||
|
{% else %}
|
||||||
|
<div class="thumbnail-placeholder">No thumbnail</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="download-info">
|
||||||
|
<h3 class="download-title">
|
||||||
|
<a href="/watch/{{ video.id }}">{{ video.title }}</a>
|
||||||
|
</h3>
|
||||||
|
<p class="download-channel">{{ video.channel.title }}</p>
|
||||||
|
<div class="download-meta">
|
||||||
|
<span class="download-date">Published: {{ video.published_at.strftime('%Y-%m-%d') }}</span>
|
||||||
|
{% if video.download_started_at %}
|
||||||
|
<span class="download-date">Started: {{ video.download_started_at.strftime('%Y-%m-%d %H:%M') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if video.download_completed_at %}
|
||||||
|
<span class="download-date">Completed: {{ video.download_completed_at.strftime('%Y-%m-%d %H:%M') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="download-status-section">
|
||||||
|
{% if video.download_status.value == 'completed' %}
|
||||||
|
<span class="badge badge-success">Completed</span>
|
||||||
|
{% if video.file_size %}
|
||||||
|
<span class="download-size">{{ (video.file_size / 1024 / 1024) | round(2) }} MB</span>
|
||||||
|
{% endif %}
|
||||||
|
{% elif video.download_status.value == 'downloading' %}
|
||||||
|
<span class="badge badge-info">Downloading...</span>
|
||||||
|
{% elif video.download_status.value == 'failed' %}
|
||||||
|
<span class="badge badge-error">Failed</span>
|
||||||
|
{% if video.download_error %}
|
||||||
|
<p class="download-error">{{ video.download_error }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-info">Pending</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="download-actions">
|
||||||
|
{% if video.download_status.value == 'pending' or video.download_status.value == 'failed' %}
|
||||||
|
<form method="POST" action="/api/download/{{ video.id }}" style="display: inline;">
|
||||||
|
<button type="submit" class="btn btn-primary">Download</button>
|
||||||
|
</form>
|
||||||
|
{% elif video.download_status.value == 'completed' %}
|
||||||
|
<a href="/watch/{{ video.id }}" class="btn btn-primary">Watch</a>
|
||||||
|
<a href="/api/video/stream/{{ video.id }}?download=1" class="btn btn-secondary">Download File</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<h3>No Videos Yet</h3>
|
||||||
|
<p>Subscribe to channels to see videos here.</p>
|
||||||
|
<a href="/add-channel" class="btn btn-primary">Add Channel</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -5,12 +5,12 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="watch-page">
|
<div class="watch-page">
|
||||||
<div class="video-player-container">
|
<div class="video-player-container">
|
||||||
{% if video.download_status == 'completed' and video.download_path %}
|
{% if video.download_status.value == 'completed' and video.download_path %}
|
||||||
<video controls class="video-player">
|
<video controls class="video-player">
|
||||||
<source src="/api/video/stream/{{ video.id }}" type="video/mp4">
|
<source src="/api/video/stream/{{ video.id }}" type="video/mp4">
|
||||||
Your browser does not support the video tag.
|
Your browser does not support the video tag.
|
||||||
</video>
|
</video>
|
||||||
{% elif video.download_status == 'downloading' %}
|
{% elif video.download_status.value == 'downloading' %}
|
||||||
<div class="video-placeholder">
|
<div class="video-placeholder">
|
||||||
<div class="placeholder-content">
|
<div class="placeholder-content">
|
||||||
<h3>Video is downloading...</h3>
|
<h3>Video is downloading...</h3>
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
<button class="btn btn-secondary" onclick="location.reload()">Refresh</button>
|
<button class="btn btn-secondary" onclick="location.reload()">Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% elif video.download_status == 'failed' %}
|
{% elif video.download_status.value == 'failed' %}
|
||||||
<div class="video-placeholder error">
|
<div class="video-placeholder error">
|
||||||
<div class="placeholder-content">
|
<div class="placeholder-content">
|
||||||
<h3>Download failed</h3>
|
<h3>Download failed</h3>
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="video-stats">
|
<div class="video-stats">
|
||||||
<span class="publish-date">Published: {{ video.published_at.strftime('%B %d, %Y') }}</span>
|
<span class="publish-date">Published: {{ video.published_at.strftime('%B %d, %Y') }}</span>
|
||||||
{% if video.download_status == 'completed' and video.download_completed_at %}
|
{% if video.download_status.value == 'completed' and video.download_completed_at %}
|
||||||
<span class="download-date">Downloaded: {{ video.download_completed_at.strftime('%B %d, %Y') }}</span>
|
<span class="download-date">Downloaded: {{ video.download_completed_at.strftime('%B %d, %Y') }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -64,8 +64,9 @@
|
|||||||
|
|
||||||
<div class="video-links">
|
<div class="video-links">
|
||||||
<a href="{{ video.video_url }}" target="_blank" class="btn btn-link">Watch on YouTube</a>
|
<a href="{{ video.video_url }}" target="_blank" class="btn btn-link">Watch on YouTube</a>
|
||||||
{% if video.download_status == 'completed' and video.download_path %}
|
{% if video.download_status.value == 'completed' and video.download_path %}
|
||||||
<a href="/api/video/stream/{{ video.id }}?download=1" class="btn btn-secondary" download>Download MP4</a>
|
<a href="/api/video/stream/{{ video.id }}?download=1" class="btn btn-secondary" download>Download MP4</a>
|
||||||
|
<button class="btn btn-danger" onclick="deleteFromDisk()">Delete from Disk</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,8 +103,25 @@
|
|||||||
startDownload();
|
startDownload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deleteFromDisk() {
|
||||||
|
fetch('/api/videos/{{ video.id }}/file', {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Failed to delete video: ' + data.message);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
alert('Error: ' + error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-refresh if video is downloading
|
// Auto-refresh if video is downloading
|
||||||
{% if video.download_status == 'downloading' %}
|
{% if video.download_status.value == 'downloading' %}
|
||||||
setTimeout(() => location.reload(), 10000); // Refresh every 10 seconds
|
setTimeout(() => location.reload(), 10000); // Refresh every 10 seconds
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
53
verify_schedule.py
Normal file
53
verify_schedule.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""Verification script for midnight video downloads."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
from scheduled_tasks import check_and_download_latest_videos
|
||||||
|
from database import SessionLocal
|
||||||
|
from models import Channel, VideoEntry, DownloadStatus
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def verify_task():
|
||||||
|
logger.info("Starting verification...")
|
||||||
|
|
||||||
|
# Run the task synchronously
|
||||||
|
check_and_download_latest_videos()
|
||||||
|
|
||||||
|
logger.info("Task completed. Checking database...")
|
||||||
|
|
||||||
|
session = SessionLocal()
|
||||||
|
try:
|
||||||
|
channels = session.query(Channel).all()
|
||||||
|
for channel in channels:
|
||||||
|
logger.info(f"Checking channel: {channel.title}")
|
||||||
|
|
||||||
|
# Get latest video
|
||||||
|
latest_video = session.query(VideoEntry)\
|
||||||
|
.filter_by(channel_id=channel.id)\
|
||||||
|
.order_by(VideoEntry.published_at.desc())\
|
||||||
|
.first()
|
||||||
|
|
||||||
|
if latest_video:
|
||||||
|
logger.info(f" Latest video: {latest_video.title}")
|
||||||
|
logger.info(f" Status: {latest_video.download_status.value}")
|
||||||
|
|
||||||
|
# Check if it was queued (status should be DOWNLOADING or COMPLETED if it was fast enough,
|
||||||
|
# or PENDING if the worker hasn't picked it up yet but the task logic ran.
|
||||||
|
# Wait, the task logic calls .delay(), so the status update happens in download_video task.
|
||||||
|
# The scheduled task only queues it.
|
||||||
|
# However, since we are running without a worker, .delay() might just push to Redis.
|
||||||
|
# But wait, if we want to verify the logic of the scheduled task, we just need to see if it CALLED .delay().
|
||||||
|
# We can't easily check that without mocking or checking side effects.
|
||||||
|
# But we can check if new videos were added (fetched).
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
logger.info(" No videos found.")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
verify_task()
|
||||||
Reference in New Issue
Block a user