diff --git a/Dockerfile b/Dockerfile index 54d60d1..440a152 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,7 @@ RUN apt-get update && apt-get install -y \ # Install uv for faster Python package management 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 WORKDIR /app @@ -30,4 +30,4 @@ RUN mkdir -p downloads EXPOSE 5000 # 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"] diff --git a/docker-compose.yml b/docker-compose.yml index b5add2b..68ad827 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -version: '3.8' +version: "3.8" services: postgres: @@ -32,14 +32,14 @@ services: app: build: . 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: DATABASE_URL: postgresql://${POSTGRES_USER:-yottob}:${POSTGRES_PASSWORD:-yottob_password}@postgres:5432/${POSTGRES_DB:-yottob} CELERY_BROKER_URL: redis://redis:6379/0 CELERY_RESULT_BACKEND: redis://redis:6379/0 FLASK_ENV: ${FLASK_ENV:-development} ports: - - "5000:5000" + - "5123:5000" volumes: - ./downloads:/app/downloads - ./:/app @@ -53,7 +53,7 @@ services: celery: build: . container_name: yottob-celery - command: celery -A celery_app worker --loglevel=info + command: uv run celery -A celery_app worker --loglevel=info environment: DATABASE_URL: postgresql://${POSTGRES_USER:-yottob}:${POSTGRES_PASSWORD:-yottob_password}@postgres:5432/${POSTGRES_DB:-yottob} CELERY_BROKER_URL: redis://redis:6379/0 diff --git a/download_service.py b/download_service.py index 3453b01..5e64539 100644 --- a/download_service.py +++ b/download_service.py @@ -58,8 +58,8 @@ def download_video(self, video_id: int) -> dict: session.commit() try: - # Extract video ID from YouTube URL - youtube_url = video.link + # Get video URL from database + youtube_url = video.video_url # Configure yt-dlp options for MP4 output ydl_opts = { diff --git a/feed_parser.py b/feed_parser.py index 50778bc..b043477 100644 --- a/feed_parser.py +++ b/feed_parser.py @@ -70,7 +70,7 @@ class YouTubeFeedParser: entries = [] for entry in feed.entries: - if filter_shorts and "shorts" in entry.link: + if filter_shorts and "shorts" in entry.link.lower(): continue # Extract video ID from URL diff --git a/main.py b/main.py index 68f67e3..26c17de 100644 --- a/main.py +++ b/main.py @@ -24,15 +24,12 @@ login_manager.login_message_category = "info" @login_manager.user_loader def load_user(user_id): """Load user by ID for Flask-Login.""" - session = next(get_db_session()) - try: + with get_db_session() as session: user = session.query(User).get(int(user_id)) if user: # Expire the user from this session so it can be used in request context session.expunge(user) return user - finally: - session.close() # Default channel ID for demonstration DEFAULT_CHANNEL_ID = "UCtTWOND3uyl4tVc_FarDmpw" @@ -184,6 +181,32 @@ def channels_page(): 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"]) @login_required def add_channel_page(): @@ -192,34 +215,33 @@ def add_channel_page(): return render_template("add_channel.html") # Handle POST - add new channel - rss_url = request.form.get("rss_url") - if not rss_url: - flash("RSS URL is required", "error") + channel_id = request.form.get("channel_id") + if not channel_id: + flash("Channel ID is required", "error") 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: - 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")) + # 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")) + try: # Fetch feed using parser parser = YouTubeFeedParser(channel_id) result = parser.fetch_feed() 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")) # Save to database with current user with get_db_session() as session: channel = parser.save_to_db(session, result, current_user.id) + channel_title = channel.title 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")) except Exception as e: @@ -255,18 +277,16 @@ def get_feed(): Query parameters: 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) Returns: JSON response with feed data or error message """ 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" parser = YouTubeFeedParser(channel_id) - result = parser.fetch_feed(filter_shorts=filter_shorts) + result = parser.fetch_feed(filter_shorts=True) if result is None: return jsonify({"error": "Failed to fetch feed"}), 500 diff --git a/static/style.css b/static/style.css index d8327b1..dba19fe 100644 --- a/static/style.css +++ b/static/style.css @@ -6,16 +6,16 @@ } :root { - --primary-color: #ff0000; - --secondary-color: #282828; - --background-color: #0f0f0f; - --card-background: #1f1f1f; - --text-primary: #ffffff; - --text-secondary: #aaaaaa; - --border-color: #303030; - --success-color: #00aa00; - --error-color: #cc0000; - --info-color: #0066cc; + --primary-color: #000000; + --secondary-color: #f5f5f5; + --background-color: #ffffff; + --card-background: #fafafa; + --text-primary: #000000; + --text-secondary: #666666; + --border-color: #e0e0e0; + --success-color: #000000; + --error-color: #000000; + --info-color: #000000; } body { @@ -45,7 +45,7 @@ body { } .logo a { - color: var(--primary-color); + color: #000000; text-decoration: none; font-size: 1.5rem; font-weight: bold; @@ -98,18 +98,21 @@ body { } .alert-success { - background-color: rgba(0, 170, 0, 0.1); + background-color: #f5f5f5; border-color: var(--success-color); + color: var(--text-primary); } .alert-error { - background-color: rgba(204, 0, 0, 0.1); + background-color: #f5f5f5; border-color: var(--error-color); + color: var(--text-primary); } .alert-info { - background-color: rgba(0, 102, 204, 0.1); + background-color: #f5f5f5; border-color: var(--info-color); + color: var(--text-primary); } /* Dashboard */ @@ -140,7 +143,7 @@ body { .video-card:hover { 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 { @@ -188,7 +191,7 @@ body { } .video-title a:hover { - color: var(--primary-color); + text-decoration: underline; } .video-channel { @@ -217,17 +220,17 @@ body { } .badge-success { - background-color: var(--success-color); + background-color: #000000; color: white; } .badge-info { - background-color: var(--info-color); + background-color: #666666; color: white; } .badge-error { - background-color: var(--error-color); + background-color: #000000; color: white; } @@ -248,24 +251,25 @@ body { } .btn-primary { - background-color: var(--primary-color); + background-color: #000000; color: white; } .btn-secondary { - background-color: var(--border-color); - color: var(--text-primary); + background-color: #f5f5f5; + color: #000000; + border: 1px solid #e0e0e0; } .btn-link { background-color: transparent; - color: var(--primary-color); - border: 1px solid var(--primary-color); + color: #000000; + border: 1px solid #000000; } .btn-download { background-color: transparent; - color: var(--primary-color); + color: #000000; border: none; cursor: pointer; font-size: 0.85rem; @@ -331,7 +335,7 @@ body { } .channel-url a:hover { - color: var(--primary-color); + text-decoration: underline; } .channel-meta { @@ -346,6 +350,103 @@ body { 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-container { max-width: 600px; @@ -497,7 +598,7 @@ body { } .channel-link:hover { - color: var(--primary-color); + text-decoration: underline; } .video-stats { @@ -536,7 +637,7 @@ body { } .back-link a:hover { - color: var(--primary-color); + text-decoration: underline; } /* Auth Pages */ @@ -554,7 +655,8 @@ body { background-color: var(--card-background); padding: 3rem; 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 { @@ -605,8 +707,9 @@ body { } .auth-footer a { - color: var(--primary-color); + color: #000000; text-decoration: none; + font-weight: 600; } .auth-footer a:hover { @@ -657,4 +760,17 @@ body { .video-stats { align-items: flex-start; } + + .download-card { + grid-template-columns: 1fr; + gap: 1rem; + } + + .download-thumbnail { + width: 100%; + } + + .download-actions { + width: 100%; + } } diff --git a/templates/add_channel.html b/templates/add_channel.html index b81fbf3..cb6d1f1 100644 --- a/templates/add_channel.html +++ b/templates/add_channel.html @@ -12,18 +12,17 @@
youtube.com/channel/CHANNEL_ID, copy the CHANNEL_ID partyoutube.com/@username, you'll need to:
youtube.com/channel/CHANNEL_ID oryoutube.com/@username (you'll need to find the channel ID from the page source)https://www.youtube.com/feeds/videos.xml?channel_id=CHANNEL_ID
- For channel: https://www.youtube.com/channel/UC_x5XG1OV2P6uZZ5FSM9Ttw
- RSS URL: https://www.youtube.com/feeds/videos.xml?channel_id=UC_x5XG1OV2P6uZZ5FSM9Ttw
+ For channel URL: https://www.youtube.com/channel/UC_x5XG1OV2P6uZZ5FSM9Ttw
+ Channel ID: UC_x5XG1OV2P6uZZ5FSM9Ttw
View and manage all video downloads
+{{ video.channel.title }}
+ +{{ video.download_error }}
+ {% endif %} + {% else %} + Pending + {% endif %} +