This commit is contained in:
Cody Cook 2025-06-15 21:20:30 -07:00
parent e86ab53de5
commit 095bf52a2f
29 changed files with 2494 additions and 758 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
/instance.zip
/instance/podcastrr.db
/podcastrr.db
/.venv/

View file

@ -9,7 +9,7 @@ class Podcast(db.Model):
Model representing a podcast.
"""
__tablename__ = 'podcasts'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(255), nullable=False)
author = db.Column(db.String(255))
@ -20,13 +20,15 @@ class Podcast(db.Model):
last_updated = db.Column(db.DateTime, default=datetime.utcnow)
last_checked = db.Column(db.DateTime, default=datetime.utcnow)
auto_download = db.Column(db.Boolean, default=False)
naming_format = db.Column(db.String(255), nullable=True) # If null, use global settings
episode_ordering = db.Column(db.String(20), default='absolute') # 'absolute' or 'season_episode'
# Relationships
episodes = db.relationship('Episode', backref='podcast', lazy='dynamic', cascade='all, delete-orphan')
def __repr__(self):
return f'<Podcast {self.title}>'
def to_dict(self):
"""
Convert podcast to dictionary for API responses.
@ -42,6 +44,7 @@ class Podcast(db.Model):
'last_updated': self.last_updated.isoformat() if self.last_updated else None,
'last_checked': self.last_checked.isoformat() if self.last_checked else None,
'auto_download': self.auto_download,
'naming_format': self.naming_format,
'episode_count': self.episodes.count()
}
@ -50,7 +53,7 @@ class Episode(db.Model):
Model representing a podcast episode.
"""
__tablename__ = 'episodes'
id = db.Column(db.Integer, primary_key=True)
podcast_id = db.Column(db.Integer, db.ForeignKey('podcasts.id'), nullable=False)
title = db.Column(db.String(255), nullable=False)
@ -60,14 +63,16 @@ class Episode(db.Model):
published_date = db.Column(db.DateTime)
duration = db.Column(db.Integer) # Duration in seconds
file_size = db.Column(db.Integer) # Size in bytes
episode_number = db.Column(db.String(50))
season = db.Column(db.Integer, nullable=True) # Season number
episode_number = db.Column(db.String(50)) # Episode number within season or absolute
guid = db.Column(db.String(512), unique=True)
downloaded = db.Column(db.Boolean, default=False)
file_path = db.Column(db.String(512))
explicit = db.Column(db.Boolean, nullable=True) # Whether the episode is marked as explicit
def __repr__(self):
return f'<Episode {self.title}>'
def to_dict(self):
"""
Convert episode to dictionary for API responses.
@ -82,8 +87,10 @@ class Episode(db.Model):
'published_date': self.published_date.isoformat() if self.published_date else None,
'duration': self.duration,
'file_size': self.file_size,
'season': self.season,
'episode_number': self.episode_number,
'guid': self.guid,
'downloaded': self.downloaded,
'file_path': self.file_path
}
'file_path': self.file_path,
'explicit': self.explicit
}

View file

@ -8,17 +8,17 @@ class Settings(db.Model):
Model representing application settings.
"""
__tablename__ = 'settings'
id = db.Column(db.Integer, primary_key=True)
download_path = db.Column(db.String(512), nullable=False)
naming_format = db.Column(db.String(255), nullable=False, default="{podcast_title}/{episode_title}")
auto_download = db.Column(db.Boolean, default=False)
max_downloads = db.Column(db.Integer, default=5)
delete_after_days = db.Column(db.Integer, default=30)
def __repr__(self):
return f'<Settings id={self.id}>'
def to_dict(self):
"""
Convert settings to dictionary for API responses.
@ -30,4 +30,4 @@ class Settings(db.Model):
'auto_download': self.auto_download,
'max_downloads': self.max_downloads,
'delete_after_days': self.delete_after_days
}
}

View file

@ -12,19 +12,38 @@ from app.models.settings import Settings
# Set up logging
logger = logging.getLogger(__name__)
def download_episode(episode):
def download_episode(episode_id, progress_callback=None):
"""
Download a podcast episode.
Args:
episode: Episode model instance.
episode_id: ID of the Episode to download.
progress_callback (callable, optional): Callback function for progress updates.
Returns:
str: Path to the downloaded file.
"""
from app.models.podcast import Episode, Podcast
if progress_callback:
progress_callback(2, "Loading episode data")
# Load the episode with its podcast relationship
episode = Episode.query.get(episode_id)
if not episode:
raise ValueError(f"Episode with ID {episode_id} not found")
# Explicitly load the podcast to avoid lazy loading issues
podcast = Podcast.query.get(episode.podcast_id)
if not podcast:
raise ValueError(f"Podcast with ID {episode.podcast_id} not found")
if not episode.audio_url:
raise ValueError("Episode has no audio URL")
if progress_callback:
progress_callback(5, "Getting settings")
# Get settings
settings = Settings.query.first()
if not settings:
@ -39,20 +58,28 @@ def download_episode(episode):
download_path = settings.download_path
os.makedirs(download_path, exist_ok=True)
if progress_callback:
progress_callback(10, "Formatting filename")
# Use podcast's naming format if available, otherwise use global settings
naming_format = podcast.naming_format or settings.naming_format
# Format filename using the naming format
podcast = episode.podcast
filename = format_filename(settings.naming_format, podcast, episode)
filename = format_filename(naming_format, podcast, episode)
# Ensure the directory exists
file_dir = os.path.dirname(os.path.join(download_path, filename))
os.makedirs(file_dir, exist_ok=True)
# Add file extension based on content type
file_path = os.path.join(download_path, filename)
file_path = os.path.normpath(os.path.join(download_path, filename))
# Download the file
try:
response = requests.get(episode.audio_url, stream=True)
if progress_callback:
progress_callback(15, "Connecting to server")
response = requests.get(episode.audio_url, stream=True, timeout=30)
response.raise_for_status()
# Get content type and set appropriate extension
@ -70,17 +97,37 @@ def download_episode(episode):
else:
file_path += '.mp3' # Default to mp3
# Get file size if available
file_size = int(response.headers.get('Content-Length', 0))
episode.file_size = file_size
if progress_callback:
progress_callback(20, "Starting download")
# Write the file
downloaded_bytes = 0
with open(file_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
downloaded_bytes += len(chunk)
# Update progress if file size is known
if file_size > 0 and progress_callback:
progress = 20 + int((downloaded_bytes / file_size) * 70) # Scale to 20-90%
progress_callback(min(progress, 90), f"Downloading: {downloaded_bytes/1024/1024:.1f}MB / {file_size/1024/1024:.1f}MB")
if progress_callback:
progress_callback(95, "Updating database")
# Update episode in database
episode.downloaded = True
episode.file_path = file_path
db.session.commit()
if progress_callback:
progress_callback(100, "Download complete")
logger.info(f"Downloaded episode: {episode.title}")
return file_path
@ -100,22 +147,58 @@ def format_filename(format_string, podcast, episode):
Returns:
str: Formatted filename.
"""
# Calculate absolute number if needed
absolute_number = ''
if '{absolute_number}' in format_string:
from app.models.podcast import Episode
# Get all episodes for this podcast ordered by published date
episodes = Episode.query.filter_by(podcast_id=podcast.id).order_by(Episode.published_date.asc()).all()
# Find the position of the current episode in the ordered list
for i, ep in enumerate(episodes, 1):
if ep.id == episode.id:
absolute_number = str(i)
break
# Create a dictionary with all available variables
format_vars = {
'podcast_title': sanitize_filename(podcast.title),
'episode_title': sanitize_filename(episode.title),
'episode_number': sanitize_filename(str(episode.episode_number)) if episode.episode_number else '',
'season': sanitize_filename(str(episode.season)) if episode.season else '',
# Format season_episode as S01E01, ensuring season is always included
'season_episode': (
# If we have season and episode_number is a digit, format as S01E01
f"S{episode.season:02d}E{int(episode.episode_number):02d}"
if episode.season and episode.episode_number and episode.episode_number.isdigit()
# If episode_number exists but is not a digit, format as S01E{episode_number}
else f"S{episode.season or 1:02d}E{episode.episode_number}"
if episode.episode_number
# Otherwise, return empty string
else ''
),
'published_date': episode.published_date.strftime('%Y-%m-%d') if episode.published_date else '',
'author': sanitize_filename(podcast.author) if podcast.author else ''
'author': sanitize_filename(podcast.author) if podcast.author else '',
'explicit': 'explicit' if episode.explicit else '',
'absolute_number': sanitize_filename(absolute_number)
}
# Format the string
try:
return format_string.format(**format_vars)
formatted_path = format_string.format(**format_vars)
except KeyError as e:
logger.warning(f"Invalid format variable: {str(e)}")
# Fall back to a simple format
return f"{format_vars['podcast_title']}/{format_vars['episode_title']}"
formatted_path = f"{format_vars['podcast_title']}/{format_vars['episode_title']}"
# Replace forward slashes with OS-specific path separator
formatted_path = formatted_path.replace('/', os.path.sep)
# Handle empty path segments by removing them
path_parts = formatted_path.split(os.path.sep)
path_parts = [part for part in path_parts if part.strip()]
# Rejoin the path with proper separators
return os.path.sep.join(path_parts)
def sanitize_filename(filename):
"""
@ -127,15 +210,28 @@ def sanitize_filename(filename):
Returns:
str: Sanitized filename.
"""
if not filename:
return ""
# Replace invalid characters
invalid_chars = ['<', '>', ':', '"', '/', '\\', '|', '?', '*']
for char in invalid_chars:
filename = filename.replace(char, '_')
# Remove leading and trailing whitespace and periods
filename = filename.strip().strip('.')
# Replace multiple spaces with a single space
filename = ' '.join(filename.split())
# Limit length
if len(filename) > 100:
filename = filename[:97] + '...'
# If filename is empty after sanitization, provide a default
if not filename:
filename = "unnamed"
return filename
def delete_old_episodes(days=30):
@ -177,3 +273,141 @@ def delete_old_episodes(days=30):
db.session.commit()
logger.info(f"Deleted {count} old episodes")
return count
def verify_downloaded_episodes(podcast_id=None, progress_callback=None):
"""
Verify that downloaded episodes still exist on disk and update their status.
Args:
podcast_id (int, optional): ID of the podcast to check. If None, check all podcasts.
progress_callback (callable, optional): Callback function for progress updates.
Returns:
dict: Statistics about the verification process.
"""
from app.models.podcast import Episode, Podcast
# Get episodes to check
query = Episode.query.filter(Episode.downloaded == True)
if podcast_id:
query = query.filter(Episode.podcast_id == podcast_id)
episodes = query.all()
total = len(episodes)
if progress_callback:
progress_callback(0, f"Verifying {total} downloaded episodes")
missing = 0
for i, episode in enumerate(episodes):
if progress_callback and total > 0:
progress = int((i / total) * 100)
progress_callback(progress, f"Verifying episode {i+1}/{total}")
if not episode.file_path or not os.path.exists(episode.file_path):
episode.downloaded = False
if episode.file_path:
logger.warning(f"Episode file not found: {episode.file_path}")
missing += 1
db.session.commit()
if progress_callback:
progress_callback(100, f"Verification complete. {missing} episodes marked as not downloaded.")
logger.info(f"Verified {total} episodes. {missing} were missing.")
return {
'total_checked': total,
'missing': missing
}
def rename_episode(episode_id, new_format=None, progress_callback=None):
"""
Rename a downloaded episode file using a new format.
Args:
episode_id: ID of the Episode to rename.
new_format (str, optional): New format string. If None, use the podcast's format or the global settings format.
progress_callback (callable, optional): Callback function for progress updates.
Returns:
str: New file path.
"""
from app.models.podcast import Episode, Podcast
if progress_callback:
progress_callback(5, "Loading episode data")
# Load the episode with its podcast relationship
episode = Episode.query.get(episode_id)
if not episode:
raise ValueError(f"Episode with ID {episode_id} not found")
if not episode.downloaded or not episode.file_path or not os.path.exists(episode.file_path):
raise ValueError("Episode is not downloaded or file does not exist")
if progress_callback:
progress_callback(10, "Getting podcast and format settings")
# Explicitly load the podcast to avoid lazy loading issues
podcast = Podcast.query.get(episode.podcast_id)
if not podcast:
raise ValueError(f"Podcast with ID {episode.podcast_id} not found")
settings = Settings.query.first()
if not settings:
settings = Settings(
download_path=current_app.config['DOWNLOAD_PATH'],
naming_format="{podcast_title}/{episode_title}"
)
db.session.add(settings)
db.session.commit()
# Use provided format, podcast's format, or global settings format
format_string = new_format or podcast.naming_format or settings.naming_format
if progress_callback:
progress_callback(20, "Formatting new filename")
# Format new filename
new_filename = format_filename(format_string, podcast, episode)
# Get file extension from current file
_, ext = os.path.splitext(episode.file_path)
# Create full path for new file
download_path = settings.download_path
new_file_path = os.path.normpath(os.path.join(download_path, new_filename + ext))
# Ensure the directory exists
new_file_dir = os.path.dirname(new_file_path)
os.makedirs(new_file_dir, exist_ok=True)
if progress_callback:
progress_callback(50, f"Renaming file to {new_file_path}")
# Rename the file
try:
# Check if the new path is different
if os.path.normpath(episode.file_path) != os.path.normpath(new_file_path):
os.rename(episode.file_path, new_file_path)
episode.file_path = new_file_path
db.session.commit()
if progress_callback:
progress_callback(100, "File renamed successfully")
logger.info(f"Renamed episode file: {episode.title} to {new_file_path}")
return new_file_path
else:
if progress_callback:
progress_callback(100, "File already has the correct name")
logger.info(f"Episode file already has the correct name: {new_file_path}")
return episode.file_path
except Exception as e:
if progress_callback:
progress_callback(100, f"Error renaming file: {str(e)}")
logger.error(f"Error renaming episode file: {str(e)}")
raise

View file

@ -142,9 +142,16 @@ def get_podcast_episodes(feed_url):
'published_date': _parse_date(entry.get('published')),
'guid': entry.get('id', ''),
'duration': _parse_duration(entry.get('itunes_duration', '')),
'episode_number': entry.get('itunes_episode', '')
'season': entry.get('itunes_season'), # Season number
'episode_number': entry.get('itunes_episode', ''), # Episode number within season
'explicit': False # Default to False
}
# Handle explicit flag safely
itunes_explicit = entry.get('itunes_explicit', '')
if isinstance(itunes_explicit, str) and itunes_explicit:
episode['explicit'] = itunes_explicit.lower() == 'yes'
# Generate a GUID if one is not provided
if not episode['guid']:
# Try to use a link as GUID

View file

@ -41,12 +41,13 @@ def update_all_podcasts():
return stats
def update_podcast(podcast_id):
def update_podcast(podcast_id, progress_callback=None):
"""
Update a specific podcast.
Args:
podcast_id (int): ID of the podcast to update.
progress_callback (callable, optional): Callback function for progress updates.
Returns:
dict: Statistics about the update process.
@ -63,12 +64,18 @@ def update_podcast(podcast_id):
logger.info(f"Updating podcast: {podcast.title} (ID: {podcast.id})")
logger.info(f"Feed URL: {podcast.feed_url}")
if progress_callback:
progress_callback(10, f"Fetching episodes for {podcast.title}")
# Get episodes from feed
episodes = get_podcast_episodes(podcast.feed_url)
# Update podcast last_checked timestamp
podcast.last_checked = datetime.utcnow()
if progress_callback:
progress_callback(30, f"Found {len(episodes)} episodes")
if not episodes:
logger.warning(f"No episodes found for podcast: {podcast.title}")
stats['feed_status'] = 'no_episodes'
@ -92,7 +99,11 @@ def update_podcast(podcast_id):
logger.error(f"Error refreshing feed URL: {str(e)}")
# Process each episode
for episode_data in episodes:
total_episodes = len(episodes)
for i, episode_data in enumerate(episodes):
if progress_callback and total_episodes > 0:
progress = 30 + int((i / total_episodes) * 60) # Scale from 30% to 90%
progress_callback(progress, f"Processing episode {i+1}/{total_episodes}")
# Skip episodes without required fields
if not episode_data.get('guid'):
logger.warning(f"Skipping episode without GUID: {episode_data.get('title', 'Unknown')}")
@ -129,7 +140,9 @@ def update_podcast(podcast_id):
# Auto-download if enabled
if podcast.auto_download and episode.audio_url:
try:
download_episode(episode)
# Need to commit first to ensure episode has an ID
db.session.commit()
download_episode(episode.id)
stats['episodes_downloaded'] += 1
logger.info(f"Auto-downloaded episode: {episode.title}")
except Exception as e:
@ -144,6 +157,9 @@ def update_podcast(podcast_id):
db.session.commit()
logger.info(f"Podcast update completed: {stats}")
if progress_callback:
progress_callback(100, f"Update complete. Found {stats['new_episodes']} new episodes.")
return stats
except Exception as e:
@ -151,6 +167,10 @@ def update_podcast(podcast_id):
logger.error(f"Error updating podcast {podcast.title}: {str(e)}")
stats['feed_status'] = 'error'
stats['error'] = str(e)
if progress_callback:
progress_callback(100, f"Error: {str(e)}")
raise
def schedule_updates():

View file

@ -0,0 +1,200 @@
"""
Task manager service for Podcastrr.
Handles background tasks and provides status updates.
"""
import threading
import time
import uuid
import logging
from datetime import datetime
from enum import Enum
from flask import current_app
# Set up logging
logger = logging.getLogger(__name__)
class TaskStatus(Enum):
"""Task status enum"""
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
class Task:
"""
Represents a background task with status tracking.
"""
def __init__(self, task_type, description, target_func, target_args=None, target_kwargs=None):
self.id = str(uuid.uuid4())
self.type = task_type
self.description = description
self.status = TaskStatus.PENDING
self.progress = 0
self.message = "Task created"
self.result = None
self.error = None
self.created_at = datetime.utcnow()
self.started_at = None
self.completed_at = None
self.target_func = target_func
self.target_args = target_args or []
self.target_kwargs = target_kwargs or {}
def to_dict(self):
"""Convert task to dictionary for API responses."""
return {
'id': self.id,
'type': self.type,
'description': self.description,
'status': self.status.value,
'progress': self.progress,
'message': self.message,
'created_at': self.created_at.isoformat() if self.created_at else None,
'started_at': self.started_at.isoformat() if self.started_at else None,
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
'error': self.error
}
class TaskManager:
"""
Manages background tasks and provides status updates.
"""
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super(TaskManager, cls).__new__(cls)
cls._instance.tasks = {}
cls._instance.lock = threading.Lock()
return cls._instance
def create_task(self, task_type, description, target_func, *args, **kwargs):
"""
Create a new task and add it to the task manager.
Args:
task_type (str): Type of task (e.g., 'download', 'update')
description (str): Human-readable description of the task
target_func (callable): Function to execute in the background
*args: Arguments to pass to the target function
**kwargs: Keyword arguments to pass to the target function
Returns:
str: Task ID
"""
task = Task(task_type, description, target_func, args, kwargs)
with self.lock:
self.tasks[task.id] = task
# Get the current Flask app
app = current_app._get_current_object()
# Start the task in a background thread
thread = threading.Thread(target=self._run_task, args=(task.id, app))
thread.daemon = True
thread.start()
logger.info(f"Created task {task.id} of type {task_type}: {description}")
return task.id
def _run_task(self, task_id, app):
"""
Run a task in the background.
Args:
task_id (str): ID of the task to run
app: Flask application instance
"""
task = self.get_task(task_id)
if not task:
logger.error(f"Task {task_id} not found")
return
# Update task status
task.status = TaskStatus.RUNNING
task.started_at = datetime.utcnow()
task.message = "Task started"
try:
# Create a wrapper for the target function to update progress
def progress_callback(progress, message=None):
task.progress = progress
if message:
task.message = message
# Add progress_callback to kwargs
task.target_kwargs['progress_callback'] = progress_callback
# Run the target function within Flask application context
with app.app_context():
result = task.target_func(*task.target_args, **task.target_kwargs)
# Update task status
task.status = TaskStatus.COMPLETED
task.completed_at = datetime.utcnow()
task.progress = 100
task.result = result
task.message = "Task completed successfully"
logger.info(f"Task {task_id} completed successfully")
except Exception as e:
# Update task status on error
task.status = TaskStatus.FAILED
task.completed_at = datetime.utcnow()
task.error = str(e)
task.message = f"Task failed: {str(e)}"
logger.error(f"Task {task_id} failed: {str(e)}", exc_info=True)
# Clean up old tasks
self.clean_old_tasks()
def get_task(self, task_id):
"""
Get a task by ID.
Args:
task_id (str): ID of the task to get
Returns:
Task: Task object or None if not found
"""
with self.lock:
return self.tasks.get(task_id)
def get_all_tasks(self):
"""
Get all tasks.
Returns:
list: List of all tasks
"""
with self.lock:
return list(self.tasks.values())
def clean_old_tasks(self, max_age_seconds=60):
"""
Remove old completed or failed tasks.
Args:
max_age_seconds (int): Maximum age of tasks to keep in seconds
Returns:
int: Number of tasks removed
"""
now = datetime.utcnow()
to_remove = []
with self.lock:
for task_id, task in self.tasks.items():
if task.status in (TaskStatus.COMPLETED, TaskStatus.FAILED):
if task.completed_at and (now - task.completed_at).total_seconds() > max_age_seconds:
to_remove.append(task_id)
for task_id in to_remove:
del self.tasks[task_id]
return len(to_remove)
# Create a global task manager instance
task_manager = TaskManager()

View file

@ -206,10 +206,11 @@ def download_episode_api(episode_id):
"""
Download an episode.
"""
# Verify episode exists
episode = Episode.query.get_or_404(episode_id)
try:
download_path = download_episode(episode)
download_path = download_episode(episode_id)
return jsonify({
'message': 'Episode downloaded successfully',
'path': download_path

98
app/web/routes/debug.py Normal file
View file

@ -0,0 +1,98 @@
"""
Debug routes for troubleshooting Podcastrr issues.
"""
from flask import Blueprint, jsonify, render_template, request
from app.models.podcast import Podcast
from app.services.podcast_updater import PodcastUpdater
import logging
debug_bp = Blueprint('debug', __name__, url_prefix='/debug')
@debug_bp.route('/test-feed')
def test_feed():
"""
Test RSS feed parsing for debugging.
"""
feed_url = request.args.get('url')
if not feed_url:
return jsonify({'error': 'No feed URL provided'}), 400
try:
updater = PodcastUpdater()
# Create a temporary podcast object for testing
temp_podcast = type('obj', (object,), {
'id': 0,
'feed_url': feed_url,
'title': 'Test Podcast'
})
# Try to fetch episodes
logging.info(f"Testing feed URL: {feed_url}")
episodes = updater.fetch_episodes(temp_podcast)
return jsonify({
'success': True,
'feed_url': feed_url,
'episodes_found': len(episodes),
'episodes': [
{
'title': ep.get('title', 'No title'),
'description': ep.get('description', 'No description')[:100] + '...' if ep.get(
'description') else 'No description',
'pub_date': str(ep.get('pub_date', 'No date')),
'audio_url': ep.get('audio_url', 'No audio URL')
}
for ep in episodes[:5] # Show first 5 episodes
]
})
except Exception as e:
logging.error(f"Error testing feed {feed_url}: {str(e)}")
return jsonify({
'success': False,
'error': str(e),
'feed_url': feed_url
}), 500
@debug_bp.route('/podcast-info/<int:podcast_id>')
def podcast_info(podcast_id):
"""
Get detailed information about a podcast for debugging.
"""
try:
podcast = Podcast.query.get_or_404(podcast_id)
return jsonify({
'success': True,
'podcast': {
'id': podcast.id,
'title': podcast.title,
'author': podcast.author,
'feed_url': podcast.feed_url,
'image_url': podcast.image_url,
'description': podcast.description[:200] + '...' if podcast.description else None,
'last_updated': str(podcast.last_updated) if podcast.last_updated else None,
'episode_count': len(podcast.episodes) if hasattr(podcast, 'episodes') else 0
}
})
except Exception as e:
logging.error(f"Error getting podcast info for ID {podcast_id}: {str(e)}")
return jsonify({
'success': False,
'error': str(e)
}), 500
@debug_bp.route('/logs')
def view_logs():
"""
View recent application logs.
"""
try:
# This is a simple log viewer - in production you'd want proper log management
return render_template('debug/logs.html')
except Exception as e:
return jsonify({'error': str(e)}), 500

View file

@ -13,7 +13,7 @@ def index():
"""
# Get recent podcasts
recent_podcasts = Podcast.query.order_by(Podcast.last_updated.desc()).limit(5).all()
return render_template('index.html',
title='Home',
recent_podcasts=recent_podcasts)
@ -32,7 +32,7 @@ def dashboard():
"""
# Get statistics
total_podcasts = Podcast.query.count()
return render_template('dashboard.html',
title='Dashboard',
total_podcasts=total_podcasts)
total_podcasts=total_podcasts)

View file

@ -31,7 +31,9 @@ def search():
if request.method == 'POST':
query = request.form.get('query', '')
if query:
logger.info(f"Searching for podcasts with query: {query}")
results = search_podcasts(query)
logger.info(f"Found {len(results)} results")
return render_template('podcasts/search.html',
title='Search Podcasts',
@ -42,33 +44,61 @@ def add(podcast_id):
"""
Add a podcast to track.
"""
logger.info(f"Adding podcast with ID: {podcast_id}")
# Check if podcast already exists
existing = Podcast.query.filter_by(external_id=podcast_id).first()
if existing:
flash('Podcast is already being tracked.', 'info')
return redirect(url_for('podcasts.index'))
# Get podcast details from service
podcast_data = search_podcasts(podcast_id=podcast_id)
if not podcast_data:
flash('Failed to get podcast details.', 'error')
return redirect(url_for('podcasts.search'))
logger.info(f"Got podcast data: {podcast_data['title']}")
logger.info(f"Feed URL: {podcast_data.get('feed_url', 'No feed URL')}")
# Create podcast record
podcast = Podcast(
title=podcast_data['title'],
author=podcast_data['author'],
description=podcast_data['description'],
image_url=podcast_data['image_url'],
feed_url=podcast_data['feed_url'],
external_id=podcast_id
)
db.session.add(podcast)
db.session.commit()
logger.info(f"Podcast saved with ID: {podcast.id}")
# Fetch episodes immediately after adding
if podcast_data.get('feed_url'):
try:
from app.services.podcast_updater import update_podcast
logger.info(f"Fetching episodes for newly added podcast: {podcast.title}")
stats = update_podcast(podcast.id)
logger.info(f"Update stats: {stats}")
if stats and stats.get('new_episodes', 0) > 0:
flash(f'Podcast added successfully! Found {stats["new_episodes"]} episodes.', 'success')
else:
flash('Podcast added successfully! No episodes found yet. The feed might be empty or inaccessible.', 'info')
logger.warning(f"No episodes found for podcast: {podcast.title}")
except Exception as e:
logger.error(f"Error fetching episodes for new podcast: {str(e)}", exc_info=True)
flash(f'Podcast added successfully, but failed to fetch episodes: {str(e)}', 'error')
else:
# Get podcast details from service
podcast_data = search_podcasts(podcast_id=podcast_id)
flash('Podcast added successfully, but no RSS feed URL available.', 'info')
logger.warning(f"No feed URL available for podcast: {podcast.title}")
if podcast_data:
podcast = Podcast(
title=podcast_data['title'],
author=podcast_data['author'],
description=podcast_data['description'],
image_url=podcast_data['image_url'],
feed_url=podcast_data['feed_url'],
external_id=podcast_id
)
db.session.add(podcast)
db.session.commit()
flash('Podcast added successfully!', 'success')
else:
flash('Failed to add podcast.', 'error')
return redirect(url_for('podcasts.index'))
return redirect(url_for('podcasts.view', podcast_id=podcast.id))
@podcasts_bp.route('/<int:podcast_id>')
def view(podcast_id):
@ -78,6 +108,8 @@ def view(podcast_id):
podcast = Podcast.query.get_or_404(podcast_id)
episodes = Episode.query.filter_by(podcast_id=podcast_id).order_by(Episode.published_date.desc()).all()
logger.info(f"Viewing podcast: {podcast.title} with {len(episodes)} episodes")
return render_template('podcasts/view.html',
title=podcast.title,
podcast=podcast,
@ -89,6 +121,7 @@ def delete(podcast_id):
Delete a podcast from tracking.
"""
podcast = Podcast.query.get_or_404(podcast_id)
podcast_title = podcast.title
# Delete associated episodes
Episode.query.filter_by(podcast_id=podcast_id).delete()
@ -96,45 +129,126 @@ def delete(podcast_id):
db.session.delete(podcast)
db.session.commit()
flash(f'Podcast "{podcast.title}" has been deleted.', 'success')
logger.info(f"Deleted podcast: {podcast_title}")
flash(f'Podcast "{podcast_title}" has been deleted.', 'success')
return redirect(url_for('podcasts.index'))
@podcasts_bp.route('/download/<int:episode_id>')
def download(episode_id):
"""
Download an episode.
Download an episode in the background.
"""
from app.services.task_manager import task_manager
episode = Episode.query.get_or_404(episode_id)
episode_title = episode.title
podcast_id = episode.podcast_id
try:
download_path = download_episode(episode)
flash(f'Episode downloaded to {download_path}', 'success')
except Exception as e:
flash(f'Download failed: {str(e)}', 'error')
# Create a background task for the download
task_id = task_manager.create_task(
'download',
f"Downloading episode: {episode_title}",
download_episode,
episode_id
)
return redirect(url_for('podcasts.view', podcast_id=episode.podcast_id))
flash(f'Download started in the background. Check the status in the tasks panel.', 'info')
return redirect(url_for('podcasts.view', podcast_id=podcast_id))
@podcasts_bp.route('/update/<int:podcast_id>', methods=['POST'])
def update(podcast_id):
"""
Manually update a podcast to fetch new episodes.
Manually update a podcast to fetch new episodes in the background.
"""
from app.services.task_manager import task_manager
from app.services.podcast_updater import update_podcast
podcast = Podcast.query.get_or_404(podcast_id)
logger.info(f"Starting background update for podcast: {podcast.title} (ID: {podcast.id})")
# Create a background task for the update
task_id = task_manager.create_task(
'update',
f"Updating podcast: {podcast.title}",
update_podcast,
podcast_id
)
flash(f'Update started in the background. Check the status in the tasks panel.', 'info')
return redirect(url_for('podcasts.view', podcast_id=podcast_id))
@podcasts_bp.route('/verify/<int:podcast_id>', methods=['POST'])
def verify(podcast_id):
"""
Verify that downloaded episodes still exist on disk.
"""
from app.services.task_manager import task_manager
from app.services.podcast_downloader import verify_downloaded_episodes
podcast = Podcast.query.get_or_404(podcast_id)
# Create a background task for verification
task_id = task_manager.create_task(
'verify',
f"Verifying episodes for podcast: {podcast.title}",
verify_downloaded_episodes,
podcast_id
)
flash(f'Verification started in the background. Check the status in the tasks panel.', 'info')
return redirect(url_for('podcasts.view', podcast_id=podcast_id))
@podcasts_bp.route('/rename/<int:episode_id>', methods=['POST'])
def rename_episode(episode_id):
"""
Rename a downloaded episode file.
"""
from app.services.task_manager import task_manager
from app.services.podcast_downloader import rename_episode as rename_episode_func
episode = Episode.query.get_or_404(episode_id)
episode_title = episode.title
podcast_id = episode.podcast_id
# Check if episode is downloaded
if not episode.downloaded or not episode.file_path:
flash('Episode is not downloaded.', 'error')
return redirect(url_for('podcasts.view', podcast_id=podcast_id))
# Create a background task for renaming
task_id = task_manager.create_task(
'rename',
f"Renaming episode: {episode_title}",
rename_episode_func,
episode_id
)
flash(f'Renaming started in the background. Check the status in the tasks panel.', 'info')
return redirect(url_for('podcasts.view', podcast_id=podcast_id))
@podcasts_bp.route('/update_naming_format/<int:podcast_id>', methods=['POST'])
def update_naming_format(podcast_id):
"""
Update the naming format for a podcast.
"""
podcast = Podcast.query.get_or_404(podcast_id)
try:
from app.services.podcast_updater import update_podcast
# Get the naming format from the form
naming_format = request.form.get('naming_format')
logger.info(f"Manually updating podcast: {podcast.title} (ID: {podcast.id})")
stats = update_podcast(podcast_id)
# If custom format is selected, use the custom format
if naming_format == 'custom':
naming_format = request.form.get('custom_format')
if stats['new_episodes'] > 0:
flash(f"Found {stats['new_episodes']} new episodes!", 'success')
else:
flash("No new episodes found.", 'info')
# Update the podcast's naming format
podcast.naming_format = naming_format
db.session.commit()
logger.info(f"Manual update completed: {stats}")
except Exception as e:
logger.error(f"Error updating podcast: {str(e)}")
flash(f"Error updating podcast: {str(e)}", 'error')
# Flash a message to the user
if naming_format:
flash(f'Naming format updated for {podcast.title}.', 'success')
else:
flash(f'Naming format reset to global settings for {podcast.title}.', 'success')
return redirect(url_for('podcasts.view', podcast_id=podcast_id))

View file

@ -15,7 +15,7 @@ def index():
"""
# Get current settings
settings = Settings.query.first()
# If no settings exist, create default settings
if not settings:
settings = Settings(
@ -27,7 +27,7 @@ def index():
)
db.session.add(settings)
db.session.commit()
if request.method == 'POST':
# Update settings
download_path = request.form.get('download_path')
@ -35,7 +35,7 @@ def index():
auto_download = 'auto_download' in request.form
max_downloads = int(request.form.get('max_downloads', 5))
delete_after_days = int(request.form.get('delete_after_days', 30))
# Validate download path
if not os.path.exists(download_path):
try:
@ -45,22 +45,22 @@ def index():
return render_template('settings/index.html',
title='Settings',
settings=settings)
# Update settings
settings.download_path = download_path
settings.naming_format = naming_format
settings.auto_download = auto_download
settings.max_downloads = max_downloads
settings.delete_after_days = delete_after_days
db.session.commit()
# Update application config
current_app.config['DOWNLOAD_PATH'] = download_path
flash('Settings updated successfully!', 'success')
return redirect(url_for('settings.index'))
return render_template('settings/index.html',
title='Settings',
settings=settings)
@ -71,7 +71,7 @@ def naming_preview():
Preview the naming format.
"""
naming_format = request.form.get('naming_format', '')
# Example data for preview
example_data = {
'podcast_title': 'Example Podcast',
@ -79,10 +79,10 @@ def naming_preview():
'published_date': '2023-01-01',
'episode_number': '1'
}
try:
# Format the example data with the naming format
preview = naming_format.format(**example_data)
return {'preview': preview}
except Exception as e:
return {'error': str(e)}
return {'error': str(e)}

51
app/web/routes/tasks.py Normal file
View file

@ -0,0 +1,51 @@
"""
Task-related routes for the Podcastrr application.
"""
import logging
from flask import Blueprint, jsonify, request, current_app
from app.services.task_manager import task_manager
# Set up logging
logger = logging.getLogger(__name__)
tasks_bp = Blueprint('tasks', __name__)
@tasks_bp.route('/api/tasks', methods=['GET'])
def get_tasks():
"""
Get all tasks or filter by status.
"""
status = request.args.get('status')
tasks = task_manager.get_all_tasks()
if status:
tasks = [task for task in tasks if task.status.value == status]
return jsonify({
'tasks': [task.to_dict() for task in tasks]
})
@tasks_bp.route('/api/tasks/<task_id>', methods=['GET'])
def get_task(task_id):
"""
Get a specific task by ID.
"""
task = task_manager.get_task(task_id)
if not task:
return jsonify({'error': 'Task not found'}), 404
return jsonify(task.to_dict())
@tasks_bp.route('/api/tasks/clean', methods=['POST'])
def clean_tasks():
"""
Clean up old completed or failed tasks.
"""
max_age = request.json.get('max_age_seconds', 3600) if request.json else 3600
count = task_manager.clean_old_tasks(max_age)
return jsonify({
'message': f'Cleaned up {count} old tasks',
'count': count
})

View file

@ -5,29 +5,20 @@ import os
from flask import Flask
from flask_migrate import Migrate
# Import blueprints
from app.web.routes.main import main_bp
from app.web.routes.podcasts import podcasts_bp
from app.web.routes.settings import settings_bp
from app.web.routes.api import api_bp
# Import database
from app.models.database import db
def create_app(config=None):
"""
Create and configure the Flask application.
Args:
config: Configuration object or path to configuration file.
Returns:
Flask application instance.
"""
app = Flask(__name__,
template_folder='../../templates',
static_folder='../../static')
app = Flask(__name__,
template_folder='templates',
static_folder='static')
# Load default configuration
app.config.from_mapping(
SECRET_KEY=os.environ.get('SECRET_KEY', 'dev'),
@ -35,22 +26,42 @@ def create_app(config=None):
SQLALCHEMY_TRACK_MODIFICATIONS=False,
DOWNLOAD_PATH=os.environ.get('DOWNLOAD_PATH', os.path.join(os.getcwd(), 'downloads')),
)
# Load additional configuration if provided
if config:
app.config.from_mapping(config)
# Initialize extensions
# Import and initialize database
from app.models.database import db
db.init_app(app)
Migrate(app, db)
# Register blueprints
# Import and register blueprints
from app.web.routes.main import main_bp
from app.web.routes.podcasts import podcasts_bp
from app.web.routes.settings import settings_bp
from app.web.routes.api import api_bp
from app.web.routes.tasks import tasks_bp
app.register_blueprint(main_bp)
app.register_blueprint(podcasts_bp, url_prefix='/podcasts')
app.register_blueprint(settings_bp, url_prefix='/settings')
app.register_blueprint(api_bp, url_prefix='/api')
app.register_blueprint(tasks_bp)
# Ensure the download directory exists
os.makedirs(app.config['DOWNLOAD_PATH'], exist_ok=True)
return app
# Run database migrations
with app.app_context():
try:
from migrations.add_season_explicit_naming_format import run_migration
run_migration()
# Run migration to add episode_ordering column
from migrations.add_episode_ordering import run_migration as run_episode_ordering_migration
run_episode_ordering_migration()
except Exception as e:
app.logger.error(f"Error running migration: {str(e)}")
return app

View file

@ -1,6 +1,5 @@
from app.web.app import create_app
from app import create_app
from app.models.database import db
from app.models.podcast import Podcast, Episode
from app.models.settings import Settings
app = create_app()

Binary file not shown.

39
main.py
View file

@ -3,22 +3,47 @@
Podcastrr - A podcast management application similar to Sonarr but for podcasts.
"""
import os
import logging
from dotenv import load_dotenv
from app.web.app import create_app
from application import create_app
from app.models.database import db
# Load environment variables from .env file
load_dotenv()
# Create Flask application instance
app = create_app()
# Set up logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
def main():
"""
Main entry point for the application.
"""
# Create the Flask app
app = create_app()
# Create database tables if they don't exist
with app.app_context():
db.create_all()
print("Database tables created successfully!")
if __name__ == "__main__":
# Get port from environment variable or use default
port = int(os.environ.get("PORT", 5000))
debug = os.environ.get("FLASK_ENV") == "development"
print(f"Starting Podcastrr on port {port}")
print(f"Debug mode: {debug}")
# Run the application
app.run(
host="0.0.0.0",
port=port,
debug=os.environ.get("FLASK_ENV") == "development"
)
debug=debug
)
if __name__ == '__main__':
main()

3
migrations/__init__.py Normal file
View file

@ -0,0 +1,3 @@
"""
Migrations package for Podcastrr.
"""

View file

@ -0,0 +1,33 @@
"""
Migration script to add episode_ordering field to the podcasts table.
"""
import sqlite3
import os
from flask import current_app
def run_migration():
"""
Run the migration to add the episode_ordering field to the podcasts table.
"""
# Get the database path from the app config
db_path = current_app.config['SQLALCHEMY_DATABASE_URI'].replace('sqlite:///', '')
# Connect to the database
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Check if the episode_ordering column already exists in the podcasts table
cursor.execute("PRAGMA table_info(podcasts)")
columns = [column[1] for column in cursor.fetchall()]
# Add the episode_ordering column if it doesn't exist
if 'episode_ordering' not in columns:
print("Adding 'episode_ordering' column to podcasts table...")
cursor.execute("ALTER TABLE podcasts ADD COLUMN episode_ordering TEXT DEFAULT 'absolute'")
# Commit the changes and close the connection
conn.commit()
conn.close()
print("Episode ordering migration completed successfully!")
return True

View file

@ -0,0 +1,47 @@
"""
Migration script to add season, explicit, and naming_format fields to the database.
"""
import sqlite3
import os
from flask import current_app
def run_migration():
"""
Run the migration to add the new fields to the database.
"""
# Get the database path from the app config
db_path = current_app.config['SQLALCHEMY_DATABASE_URI'].replace('sqlite:///', '')
# Connect to the database
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Check if the columns already exist in the episodes table
cursor.execute("PRAGMA table_info(episodes)")
columns = [column[1] for column in cursor.fetchall()]
# Add the season column if it doesn't exist
if 'season' not in columns:
print("Adding 'season' column to episodes table...")
cursor.execute("ALTER TABLE episodes ADD COLUMN season INTEGER")
# Add the explicit column if it doesn't exist
if 'explicit' not in columns:
print("Adding 'explicit' column to episodes table...")
cursor.execute("ALTER TABLE episodes ADD COLUMN explicit BOOLEAN")
# Check if the naming_format column already exists in the podcasts table
cursor.execute("PRAGMA table_info(podcasts)")
columns = [column[1] for column in cursor.fetchall()]
# Add the naming_format column if it doesn't exist
if 'naming_format' not in columns:
print("Adding 'naming_format' column to podcasts table...")
cursor.execute("ALTER TABLE podcasts ADD COLUMN naming_format TEXT")
# Commit the changes and close the connection
conn.commit()
conn.close()
print("Migration completed successfully!")
return True

File diff suppressed because it is too large Load diff

View file

@ -8,33 +8,70 @@
{% block extra_head %}{% endblock %}
</head>
<body>
<div class="layout-container">
<!-- Header with app name and search -->
<header class="main-header">
<div class="logo">
<div class="app-container">
<!-- Sidebar -->
<nav class="sidebar">
<div class="sidebar-header">
<h1>Podcastrr</h1>
</div>
<div class="search-container">
<form action="{{ url_for('podcasts.search') }}" method="post" class="header-search-form">
<input type="text" name="query" placeholder="Search podcasts...">
<button type="submit" class="btn btn-search">Search</button>
</form>
<div class="sidebar-nav">
<ul>
<li>
<a href="{{ url_for('main.index') }}"
class="{% if request.endpoint == 'main.index' %}active{% endif %}">
Home
</a>
</li>
<li>
<a href="{{ url_for('podcasts.index') }}"
class="{% if request.endpoint in ['podcasts.index', 'podcasts.view'] %}active{% endif %}">
Podcasts
</a>
</li>
<li>
<a href="{{ url_for('podcasts.search') }}"
class="{% if request.endpoint == 'podcasts.search' %}active{% endif %}">
Add New
</a>
</li>
<li>
<a href="{{ url_for('main.dashboard') }}"
class="{% if request.endpoint == 'main.dashboard' %}active{% endif %}">
Dashboard
</a>
</li>
<li>
<a href="{{ url_for('settings.index') }}"
class="{% if request.endpoint == 'settings.index' %}active{% endif %}">
Settings
</a>
</li>
</ul>
</div>
<!-- Task Status Area -->
<div id="task-status-area" style="display: none; margin-top: auto; padding: 10px; background-color: #161b22; border-top: 1px solid #30363d;">
<div style="display: flex; align-items: center; margin-bottom: 8px;">
<h3 style="margin: 0; font-size: 14px; color: #f0f6fc;">Current Tasks</h3>
<div style="margin-left: auto;">
<button id="close-tasks-btn" class="btn btn-sm" style="padding: 2px 8px;">Close</button>
</div>
</div>
<div id="tasks-container"></div>
</div>
</header>
<!-- Left sidebar navigation -->
<nav class="sidebar">
<ul class="sidebar-nav">
<li><a href="{{ url_for('main.index') }}">Home</a></li>
<li><a href="{{ url_for('podcasts.index') }}">Podcasts</a></li>
<li><a href="{{ url_for('main.dashboard') }}">Dashboard</a></li>
<li><a href="{{ url_for('settings.index') }}">Settings</a></li>
<li><a href="{{ url_for('main.about') }}">About</a></li>
</ul>
</nav>
<!-- Main content area -->
<main class="main-content">
<!-- Main Area -->
<div class="main-area">
<!-- Top Header -->
<header class="top-header">
<div class="header-search">
<form action="{{ url_for('podcasts.search') }}" method="post">
<input type="text" name="query" placeholder="Search podcasts..." value="{{ request.form.get('query', '') }}">
</form>
</div>
</header>
<!-- Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
@ -46,15 +83,199 @@
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
</div>
</div>
<footer>
<p>&copy; 2023 Podcastrr</p>
</footer>
{% block scripts %}{% endblock %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Task status polling
let taskPollingInterval;
let activeTasks = {};
let needsRefresh = false;
let seenTasks = JSON.parse(localStorage.getItem('seenTasks') || '{}');
// Elements
const taskStatusArea = document.getElementById('task-status-area');
const tasksContainer = document.getElementById('tasks-container');
const closeTasksBtn = document.getElementById('close-tasks-btn');
// Close tasks panel
closeTasksBtn.addEventListener('click', function() {
taskStatusArea.style.display = 'none';
// Stop polling when the panel is closed
clearInterval(taskPollingInterval);
// Clean up tasks on the server
fetch('/api/tasks/clean', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ max_age_seconds: 0 }) // Clean all completed/failed tasks
});
});
// Clean up old seen tasks (older than 1 day)
cleanupSeenTasks();
// Start polling for tasks
startTaskPolling();
function startTaskPolling() {
// Initial fetch
fetchTasks();
// Set up polling interval (every 2 seconds)
taskPollingInterval = setInterval(fetchTasks, 2000);
}
function fetchTasks() {
fetch('/api/tasks')
.then(response => response.json())
.then(data => {
updateTasksUI(data.tasks);
})
.catch(error => {
console.error('Error fetching tasks:', error);
});
}
function updateTasksUI(tasks) {
// Filter for active tasks (pending or running)
const runningTasks = tasks.filter(task =>
task.status === 'pending' || task.status === 'running'
);
// Show task area if there are running tasks
if (runningTasks.length > 0) {
taskStatusArea.style.display = 'block';
needsRefresh = true;
}
// Update active tasks tracking
activeTasks = {};
runningTasks.forEach(task => {
activeTasks[task.id] = true;
});
// Clear container
tasksContainer.innerHTML = '';
// Add task items
runningTasks.forEach(task => {
const taskElement = createTaskElement(task);
tasksContainer.appendChild(taskElement);
});
// If no running tasks but we have completed/failed tasks from this session, show them
if (runningTasks.length === 0) {
// Filter out tasks that have already been seen
const recentTasks = tasks.filter(task =>
(task.status === 'completed' || task.status === 'failed') &&
new Date(task.completed_at) > new Date(Date.now() - 10000) && // Last 10 seconds
!seenTasks[task.id] // Only show tasks that haven't been seen
);
if (recentTasks.length > 0) {
taskStatusArea.style.display = 'block';
// Mark these tasks as seen with current timestamp
recentTasks.forEach(task => {
seenTasks[task.id] = new Date().getTime();
const taskElement = createTaskElement(task);
tasksContainer.appendChild(taskElement);
});
// Save seen tasks to localStorage
localStorage.setItem('seenTasks', JSON.stringify(seenTasks));
// Auto-hide after 5 seconds
setTimeout(() => {
taskStatusArea.style.display = 'none';
}, 5000);
// If we had running tasks before and now they're complete, refresh the content
// but only if we're on a page that would be affected by the task completion
if (needsRefresh && window.location.pathname.includes('/podcasts/')) {
// Add a small refresh button instead of auto-refreshing
const refreshBtn = document.createElement('button');
refreshBtn.className = 'btn btn-sm btn-primary';
refreshBtn.style.marginTop = '8px';
refreshBtn.style.width = '100%';
refreshBtn.textContent = 'Refresh Content';
refreshBtn.addEventListener('click', function() {
// Only reload the content, not the entire page
const contentArea = document.querySelector('.content-area');
if (contentArea) {
window.location.reload();
}
});
tasksContainer.appendChild(refreshBtn);
needsRefresh = false;
}
}
}
}
function cleanupSeenTasks() {
// Get the current seenTasks from localStorage
const storedTasks = JSON.parse(localStorage.getItem('seenTasks') || '{}');
const now = new Date().getTime();
const oneDayInMs = 24 * 60 * 60 * 1000; // 1 day in milliseconds
const cleanedTasks = {};
// Only keep tasks that have timestamps and are less than 1 day old
for (const taskId in storedTasks) {
const timestamp = storedTasks[taskId];
if (typeof timestamp === 'number' && now - timestamp < oneDayInMs) {
cleanedTasks[taskId] = timestamp;
} else if (storedTasks[taskId] === true) {
// For tasks without timestamps (from previous version), add a timestamp now
cleanedTasks[taskId] = now;
}
}
// Save the cleaned tasks back to localStorage
localStorage.setItem('seenTasks', JSON.stringify(cleanedTasks));
seenTasks = cleanedTasks;
}
function createTaskElement(task) {
const taskDiv = document.createElement('div');
taskDiv.className = 'task-item';
taskDiv.style.padding = '8px';
taskDiv.style.marginBottom = '8px';
taskDiv.style.backgroundColor = '#21262d';
taskDiv.style.borderRadius = '4px';
taskDiv.style.fontSize = '11px';
// Status color
let statusColor = '#7d8590'; // Default gray
if (task.status === 'running') statusColor = '#3fb950'; // Green
if (task.status === 'completed') statusColor = '#3fb950'; // Green
if (task.status === 'failed') statusColor = '#f85149'; // Red
// Create task content
const taskContent = `
<div style="display: flex; align-items: center; margin-bottom: 4px;">
<span style="font-weight: bold; color: #f0f6fc;">${task.description}</span>
<span style="margin-left: auto; color: ${statusColor}; font-size: 10px;">${task.status}</span>
</div>
<div style="margin-bottom: 6px; color: #7d8590; font-size: 10px;">${task.message || ''}</div>
<div class="progress-bar" style="height: 4px; background-color: #30363d; border-radius: 2px; overflow: hidden;">
<div class="progress-fill" style="height: 100%; width: ${task.progress}%; background-color: ${statusColor};"></div>
</div>
`;
taskDiv.innerHTML = taskContent;
return taskDiv;
}
});
</script>
</body>
</html>
</html>

View file

@ -3,9 +3,20 @@
{% block title %}Dashboard{% endblock %}
{% block content %}
<section class="dashboard">
<h2>Dashboard</h2>
<div class="content-header">
<h1 class="content-title">Dashboard</h1>
<div class="content-actions">
<button class="btn btn-sm" id="refresh-stats">Refresh</button>
</div>
</div>
<div class="toolbar">
<button class="toolbar-btn" id="update-all">Update All</button>
<button class="toolbar-btn" id="rss-sync">RSS Sync</button>
<button class="toolbar-btn" id="clean-downloads">Clean Downloads</button>
</div>
<div class="content-area">
<div class="stats-grid">
<div class="stat-card">
<h3>Total Podcasts</h3>
@ -13,41 +24,72 @@
</div>
<div class="stat-card">
<h3>Recent Episodes</h3>
<p class="stat-value">Coming Soon</p>
<h3>Episodes</h3>
<p class="stat-value">0</p>
</div>
<div class="stat-card">
<h3>Downloads</h3>
<p class="stat-value">Coming Soon</p>
<p class="stat-value">0</p>
</div>
<div class="stat-card">
<h3>Storage Used</h3>
<p class="stat-value">Coming Soon</p>
<h3>Storage</h3>
<p class="stat-value">0 GB</p>
</div>
</div>
<div class="actions">
<h3>Quick Actions</h3>
<div class="button-group">
<a href="{{ url_for('podcasts.search') }}" class="btn">Search for Podcasts</a>
<button class="btn" id="update-all">Update All Podcasts</button>
<button class="btn" id="clean-downloads">Clean Old Downloads</button>
<div style="padding: 1rem;">
<h3 style="color: #cbd5e0; margin-bottom: 1rem;">Recent Activity</h3>
<div class="empty-state" style="padding: 2rem;">
<p>No recent activity to display.</p>
</div>
</div>
</section>
</div>
{% endblock %}
{% block scripts %}
<script>
// Simple JavaScript for the buttons (to be implemented)
document.getElementById('refresh-stats').addEventListener('click', function() {
this.textContent = 'Refreshing...';
this.disabled = true;
setTimeout(() => {
this.textContent = 'Refresh';
this.disabled = false;
}, 1500);
});
document.getElementById('update-all').addEventListener('click', function() {
alert('Update all podcasts functionality coming soon!');
this.style.backgroundColor = '#5d9cec';
this.textContent = 'Updating...';
setTimeout(() => {
this.style.backgroundColor = '';
this.textContent = 'Update All';
}, 2000);
});
document.getElementById('rss-sync').addEventListener('click', function() {
this.style.backgroundColor = '#5d9cec';
this.textContent = 'Syncing...';
setTimeout(() => {
this.style.backgroundColor = '';
this.textContent = 'RSS Sync';
}, 1500);
});
document.getElementById('clean-downloads').addEventListener('click', function() {
alert('Clean old downloads functionality coming soon!');
if (confirm('Clean old downloads?')) {
this.style.backgroundColor = '#e74c3c';
this.textContent = 'Cleaning...';
setTimeout(() => {
this.style.backgroundColor = '';
this.textContent = 'Clean Downloads';
}, 1000);
}
});
</script>
{% endblock %}

138
templates/debug/logs.html Normal file
View file

@ -0,0 +1,138 @@
{ % extends
"base.html" %}
{ % block
title %}Debug
Logs
{ % endblock %}
{ % block
content %}
< div
class ="page-header" >
< h1
class ="page-title" > Debug Information < / h1 >
< / div >
< div
class ="content-area" >
< div
class ="debug-section" >
< h3 > Test
RSS
Feed < / h3 >
< form
id = "test-feed-form" >
< input
type = "url"
id = "feed-url"
placeholder = "Enter RSS feed URL"
style = "width: 400px; padding: 8px;" >
< button
type = "submit"
class ="btn btn-primary" > Test Feed < / button >
< / form >
< div
id = "feed-results"
style = "margin-top: 20px;" > < / div >
< / div >
< div
class ="debug-section" style="margin-top: 30px;" >
< h3 > Recent
Activity < / h3 >
< div
id = "recent-activity" >
< p > Check
browser
console
for detailed logs during podcast operations.< / p >
< / div >
< / div >
< / div >
< script >
document.getElementById('test-feed-form').addEventListener('submit', function(e)
{
e.preventDefault();
const
feedUrl = document.getElementById('feed-url').value;
const
resultsDiv = document.getElementById('feed-results');
if (!feedUrl)
{
resultsDiv.innerHTML = '<div style="color: #f56565;">Please enter a feed URL</div>';
return;
}
resultsDiv.innerHTML = '<div style="color: #63b3ed;">Testing feed...</div>';
fetch(` / debug / test - feed?url =${encodeURIComponent(feedUrl)}
`)
.then(response= > response.json())
.then(data= > {
if (data.success)
{
let
html = `
< div
style = "color: #48bb78; margin-bottom: 10px;" >
✓ Feed
test
successful! Found ${data.episodes_found}
episodes
< / div >
< div
style = "background: #2d3748; padding: 15px; border-radius: 5px; font-family: monospace; font-size: 12px;" >
`;
data.episodes.forEach(episode= > {
html += `
< div
style = "margin-bottom: 10px; border-bottom: 1px solid #4a5568; padding-bottom: 10px;" >
< div
style = "color: #63b3ed; font-weight: bold;" >${episode.title} < / div >
< div
style = "color: #a0aec0; margin: 5px 0;" >${episode.description} < / div >
< div
style = "color: #68d391; font-size: 11px;" > Published: ${episode.pub_date} < / div >
< div
style = "color: #fbb6ce; font-size: 11px;" > Audio: ${episode.audio_url} < / div >
< / div >
`;
});
html += '</div>';
resultsDiv.innerHTML = html;
} else {
resultsDiv.innerHTML = ` < div
style = "color: #f56565;" >✗ Error: ${data.error} < / div > `;
}
})
.catch(error= > {
resultsDiv.innerHTML = ` < div
style = "color: #f56565;" >✗ Network
error: ${error.message} < / div > `;
});
});
< / script >
{ % endblock %}

View file

@ -3,30 +3,68 @@
{% block title %}Home{% endblock %}
{% block content %}
<section class="hero">
<h2>Welcome to Podcastrr</h2>
<p>A podcast management application similar to Sonarr but for podcasts.</p>
</section>
<!-- Page Header -->
<div class="page-header">
<h1 class="page-title">Podcasts</h1>
<div class="page-actions">
<a href="{{ url_for('podcasts.search') }}" class="btn btn-primary">Add New</a>
</div>
</div>
<section class="recent-podcasts">
<h3>Recent Podcasts</h3>
<!-- Toolbar -->
<div class="toolbar">
<button class="toolbar-btn primary">Update All</button>
<button class="toolbar-btn">RSS Sync</button>
<button class="toolbar-btn">Options</button>
<button class="toolbar-btn">View</button>
<button class="toolbar-btn">Sort</button>
<button class="toolbar-btn">Filter</button>
</div>
<!-- Content -->
<div class="content-area">
{% if recent_podcasts %}
<div class="podcast-grid">
{% for podcast in recent_podcasts %}
<div class="podcast-card">
{% if podcast.image_url %}
<img src="{{ podcast.image_url }}" alt="{{ podcast.title }}">
{% else %}
<div class="no-image">No Image</div>
{% endif %}
<h4>{{ podcast.title }}</h4>
<p class="author">{{ podcast.author }}</p>
<a href="{{ url_for('podcasts.view', podcast_id=podcast.id) }}" class="btn">View Episodes</a>
</div>
{% endfor %}
</div>
<table class="data-table">
<thead>
<tr>
<th style="width: 40px;"></th>
<th>Podcast Title</th>
<th>Network</th>
<th>Quality Profile</th>
<th>Next Airing</th>
<th>Previous Airing</th>
<th>Original Language</th>
<th>Added</th>
</tr>
</thead>
<tbody>
{% for podcast in recent_podcasts %}
<tr>
<td class="cell-center">
<span class="status-badge status-active"></span>
</td>
<td class="cell-title">
<a href="{{ url_for('podcasts.view', podcast_id=podcast.id) }}">
{{ podcast.title }}
</a>
<div class="cell-secondary">{{ podcast.author or 'Unknown' }}</div>
</td>
<td class="cell-secondary">{{ podcast.author or 'Unknown' }}</td>
<td class="cell-secondary">Any</td>
<td class="cell-secondary">-</td>
<td class="cell-secondary">{{ podcast.last_updated.strftime('%Y-%m-%d') if podcast.last_updated else 'Never' }}</td>
<td class="cell-secondary">English</td>
<td class="cell-secondary">{{ podcast.last_updated.strftime('%Y-%m-%d') if podcast.last_updated else 'Unknown' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No podcasts found. <a href="{{ url_for('podcasts.search') }}">Search for podcasts</a> to get started.</p>
<div class="empty-state">
<h3>No podcasts found</h3>
<p>Get started by adding your first podcast</p>
<a href="{{ url_for('podcasts.search') }}" class="btn btn-primary">Add Podcast</a>
</div>
{% endif %}
</section>
</div>
{% endblock %}

View file

@ -1,39 +1,111 @@
{% extends "base.html" %}
{% block title %}Your Podcasts{% endblock %}
{% block title %}Podcasts{% endblock %}
{% block content %}
<section class="podcasts">
<div class="section-header">
<h2>Your Podcasts</h2>
<a href="{{ url_for('podcasts.search') }}" class="btn">Search for Podcasts</a>
<!-- Page Header -->
<div class="page-header">
<h1 class="page-title">Podcasts</h1>
<div class="page-actions">
<a href="{{ url_for('podcasts.search') }}" class="btn btn-primary">Add New</a>
</div>
</div>
<!-- Toolbar -->
<div class="toolbar">
<button class="toolbar-btn primary" onclick="updateAllPodcasts()">Update All</button>
<button class="toolbar-btn" onclick="refreshPage()">Refresh</button>
<div style="margin-left: auto;">
<span class="toolbar-btn">{{ podcasts|length }} Podcasts</span>
</div>
</div>
<!-- Content Area -->
<div class="content-area">
{% if podcasts %}
<div class="podcast-grid">
{% for podcast in podcasts %}
<div class="podcast-card">
{% if podcast.image_url %}
<img src="{{ podcast.image_url }}" alt="{{ podcast.title }}">
{% else %}
<div class="no-image">No Image</div>
{% endif %}
<h3>{{ podcast.title }}</h3>
<p class="author">{{ podcast.author }}</p>
<div class="podcast-actions">
<a href="{{ url_for('podcasts.view', podcast_id=podcast.id) }}" class="btn">View Episodes</a>
<form action="{{ url_for('podcasts.delete', podcast_id=podcast.id) }}" method="post" onsubmit="return confirm('Are you sure you want to delete this podcast?');">
<button type="submit" class="btn btn-danger">Delete</button>
<table class="data-table">
<thead>
<tr>
<th style="width: 60px;"></th>
<th>Title</th>
<th>Author</th>
<th style="width: 100px;">Episodes</th>
<th style="width: 120px;">Last Updated</th>
<th style="width: 80px;">Status</th>
<th style="width: 120px;">Actions</th>
</tr>
</thead>
<tbody>
{% for podcast in podcasts %}
<tr>
<td class="cell-center">
{% if podcast.image_url %}
<img src="{{ podcast.image_url }}" alt="{{ podcast.title }}"
style="width: 40px; height: 40px; object-fit: cover; border-radius: 4px;">
{% else %}
<div style="width: 40px; height: 40px; background-color: #30363d; border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 10px; color: #7d8590;">
No Image
</div>
{% endif %}
</td>
<td>
<div class="cell-title">
<a href="{{ url_for('podcasts.view', podcast_id=podcast.id) }}">{{ podcast.title }}</a>
</div>
</td>
<td>
<div class="cell-secondary">{{ podcast.author or 'Unknown' }}</div>
</td>
<td class="cell-center">
<span class="status-badge status-active">{{ podcast.episodes.count() }}</span>
</td>
<td class="cell-center">
<div class="cell-secondary">
{% if podcast.last_updated %}
{{ podcast.last_updated.strftime('%Y-%m-%d') }}
{% else %}
Never
{% endif %}
</div>
</td>
<td class="cell-center">
{% if podcast.episodes.count() > 0 %}
<span class="status-badge status-active">Active</span>
{% else %}
<span class="status-badge status-pending">Pending</span>
{% endif %}
</td>
<td class="cell-actions">
<form action="{{ url_for('podcasts.update', podcast_id=podcast.id) }}" method="post" style="display: inline;">
<button type="submit" class="btn btn-sm">Update</button>
</form>
</div>
</div>
{% endfor %}
</div>
<form action="{{ url_for('podcasts.delete', podcast_id=podcast.id) }}" method="post"
style="display: inline; margin-left: 4px;"
onsubmit="return confirm('Are you sure you want to delete this podcast?');">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<h3>No Podcasts Found</h3>
<p>You haven't added any podcasts yet.</p>
<a href="{{ url_for('podcasts.search') }}" class="btn">Search for Podcasts</a>
<a href="{{ url_for('podcasts.search') }}" class="btn btn-primary">Add Your First Podcast</a>
</div>
{% endif %}
</section>
</div>
<script>
function updateAllPodcasts() {
// This would trigger an update for all podcasts
alert('Update All functionality would be implemented here');
}
function refreshPage() {
window.location.reload();
}
</script>
{% endblock %}

View file

@ -0,0 +1,81 @@
<!-- Naming Format Modal -->
<div id="naming-format-modal" style="display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.7);">
<div style="background-color: #161b22; margin: 10% auto; padding: 20px; border: 1px solid #30363d; border-radius: 6px; width: 80%; max-width: 600px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<h2 style="margin: 0; font-size: 18px; color: #f0f6fc;">Configure Naming Format</h2>
<span onclick="document.getElementById('naming-format-modal').style.display='none'" style="cursor: pointer; font-size: 20px; color: #7d8590;">&times;</span>
</div>
<form action="{{ url_for('podcasts.update_naming_format', podcast_id=podcast.id) }}" method="post">
<div class="form-group">
<label for="naming-format">Naming Format:</label>
<select id="naming-format" name="naming_format" class="form-control" style="width: 100%; padding: 8px; margin-bottom: 16px; background-color: #21262d; border: 1px solid #30363d; color: #c9d1d9; border-radius: 4px;" onchange="updateCustomFormatVisibility()">
<option value="">Use Global Settings</option>
<option value="{podcast_title}/{episode_title}">Podcast Title / Episode Title</option>
<option value="{podcast_title}/{episode_number} - {episode_title}">Podcast Title / Episode Number - Episode Title</option>
<option value="{podcast_title}/S{season}E{episode_number} - {episode_title}">Podcast Title / Season Episode - Episode Title</option>
<option value="{podcast_title}/Season {season}/{episode_number} - {episode_title}">Podcast Title / Season X / Episode Number - Episode Title</option>
<option value="custom">Custom</option>
</select>
</div>
<div id="custom-format-group" class="form-group" style="display: none;">
<label for="custom-format">Custom Format:</label>
<input type="text" id="custom-format" name="custom_format" class="form-control" value="{{ podcast.naming_format }}" style="width: 100%; padding: 8px; margin-bottom: 8px; background-color: #21262d; border: 1px solid #30363d; color: #c9d1d9; border-radius: 4px;">
<div style="font-size: 11px; color: #7d8590; margin-bottom: 16px;">
Available variables: {podcast_title}, {episode_title}, {episode_number}, {season}, {season_episode}, {published_date}, {author}, {explicit}, {absolute_number}
</div>
</div>
<div style="text-align: right;">
<button type="button" onclick="document.getElementById('naming-format-modal').style.display='none'" class="btn" style="margin-right: 8px;">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
<script>
// Set the initial selected option based on the podcast's naming format
document.addEventListener('DOMContentLoaded', function() {
const namingFormatSelect = document.getElementById('naming-format');
const customFormatGroup = document.getElementById('custom-format-group');
const customFormatInput = document.getElementById('custom-format');
const podcastNamingFormat = "{{ podcast.naming_format }}";
// Set the selected option
if (podcastNamingFormat) {
let found = false;
for (let i = 0; i < namingFormatSelect.options.length; i++) {
if (namingFormatSelect.options[i].value === podcastNamingFormat) {
namingFormatSelect.selectedIndex = i;
found = true;
break;
}
}
if (!found) {
// If the format doesn't match any predefined options, select "Custom"
for (let i = 0; i < namingFormatSelect.options.length; i++) {
if (namingFormatSelect.options[i].value === "custom") {
namingFormatSelect.selectedIndex = i;
customFormatGroup.style.display = "block";
customFormatInput.value = podcastNamingFormat;
break;
}
}
}
}
});
function updateCustomFormatVisibility() {
const namingFormatSelect = document.getElementById('naming-format');
const customFormatGroup = document.getElementById('custom-format-group');
if (namingFormatSelect.value === "custom") {
customFormatGroup.style.display = "block";
} else {
customFormatGroup.style.display = "none";
}
}
</script>

View file

@ -1,43 +1,65 @@
{% extends "base.html" %}
{% block title %}Search for Podcasts{% endblock %}
{% block title %}Add New Podcast{% endblock %}
{% block content %}
<section class="search">
<h2>Search for Podcasts</h2>
<!-- Page Header -->
<div class="page-header">
<h1 class="page-title">Add New Podcast</h1>
</div>
<form method="post" class="search-form">
<!-- Search Form -->
<div class="form-container">
<form action="{{ url_for('podcasts.search') }}" method="post">
<div class="form-group">
<input type="text" name="query" id="query" placeholder="Enter podcast name or keywords" required>
<button type="submit" class="btn">Search</button>
<label for="query">Search for podcasts:</label>
<input type="text" id="query" name="query" value="{{ request.form.get('query', '') }}"
placeholder="Enter podcast name, author, or keywords..." required>
</div>
<button type="submit" class="btn btn-primary">Search</button>
</form>
</div>
<!-- Search Results -->
{% if results %}
<div class="content-area">
<div style="padding: 16px; border-bottom: 1px solid #30363d; background-color: #161b22;">
<h3 style="color: #f0f6fc; margin: 0; font-size: 14px;">Search Results ({{ results|length }})</h3>
</div>
{% if results %}
<div class="search-results">
<h3>Search Results</h3>
<div class="podcast-grid">
{% for podcast in results %}
<div class="podcast-card">
{% if podcast.image_url %}
<img src="{{ podcast.image_url }}" alt="{{ podcast.title }}">
{% for result in results %}
<div class="search-result-item">
<div class="search-result-image">
{% if result.image_url %}
<img src="{{ result.image_url }}" alt="{{ result.title }}">
{% else %}
<div class="no-image">No Image</div>
No Image
{% endif %}
<h4>{{ podcast.title }}</h4>
<p class="author">{{ podcast.author }}</p>
<p class="genre">{{ podcast.genre }}</p>
<form action="{{ url_for('podcasts.add', podcast_id=podcast.external_id) }}" method="post">
<button type="submit" class="btn">Add to Library</button>
</div>
<div class="search-result-info">
<div class="search-result-title">{{ result.title }}</div>
<div class="search-result-author">{{ result.author or 'Unknown Author' }}</div>
{% if result.description %}
<div class="search-result-description">{{ result.description }}</div>
{% endif %}
{% if result.genre %}
<div style="font-size: 10px; color: #7d8590; margin-top: 4px;">Genre: {{ result.genre }}</div>
{% endif %}
</div>
<div class="search-result-actions">
<form action="{{ url_for('podcasts.add', podcast_id=result.external_id) }}" method="post">
<button type="submit" class="btn btn-primary">Add Podcast</button>
</form>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% elif request.method == 'POST' %}
<div class="no-results">
<p>No podcasts found matching your search. Try different keywords.</p>
</div>
{% endif %}
</section>
</div>
{% elif request.method == 'POST' %}
<div class="empty-state">
<h3>No Results Found</h3>
<p>No podcasts found for your search query. Try different keywords.</p>
</div>
{% endif %}
{% endblock %}

View file

@ -3,84 +3,284 @@
{% block title %}{{ podcast.title }}{% endblock %}
{% block content %}
<section class="podcast-detail">
<div class="podcast-header">
<div class="podcast-image">
<!-- Page Header -->
<div class="page-header">
<h1 class="page-title">{{ podcast.title }}</h1>
<div class="page-actions">
<form action="{{ url_for('podcasts.update', podcast_id=podcast.id) }}" method="post" style="display: inline;">
<button type="submit" class="btn btn-primary">Update Episodes</button>
</form>
<form action="{{ url_for('podcasts.delete', podcast_id=podcast.id) }}" method="post"
style="display: inline; margin-left: 8px;"
onsubmit="return confirm('Are you sure you want to delete this podcast?');">
<button type="submit" class="btn btn-danger">Delete Podcast</button>
</form>
</div>
</div>
<!-- Podcast Info -->
<div style="padding: 16px; background-color: #161b22; border-bottom: 1px solid #30363d;">
<div style="display: flex; gap: 16px;">
<div style="flex-shrink: 0;">
{% if podcast.image_url %}
<img src="{{ podcast.image_url }}" alt="{{ podcast.title }}">
<img src="{{ podcast.image_url }}" alt="{{ podcast.title }}"
style="width: 120px; height: 120px; object-fit: cover; border-radius: 6px;">
{% else %}
<div class="no-image">No Image</div>
<div style="width: 120px; height: 120px; background-color: #21262d; border-radius: 6px; display: flex; align-items: center; justify-content: center; color: #7d8590;">
No Image
</div>
{% endif %}
</div>
<div class="podcast-info">
<h1>{{ podcast.title }}</h1>
<p class="podcast-author">{{ podcast.author }}</p>
<div class="podcast-description">
<p>{{ podcast.description }}</p>
<div style="flex: 1;">
<h2 style="color: #f0f6fc; margin-bottom: 8px; font-size: 18px;">{{ podcast.title }}</h2>
<p style="color: #7d8590; margin-bottom: 8px; font-size: 13px;">{{ podcast.author or 'Unknown Author' }}</p>
{% if podcast.description %}
<p style="color: #c9d1d9; font-size: 12px; line-height: 1.4; margin-bottom: 12px;">{{ podcast.description[:200] }}{% if podcast.description|length > 200 %}...{% endif %}</p>
{% endif %}
<div style="display: flex; gap: 16px; font-size: 11px; color: #7d8590;">
<span>Episodes: {{ episodes|length }}</span>
<span>Last Updated: {{ podcast.last_updated.strftime('%Y-%m-%d %H:%M') if podcast.last_updated else 'Never' }}</span>
<span>Last Checked: {{ podcast.last_checked.strftime('%Y-%m-%d %H:%M') if podcast.last_checked else 'Never' }}</span>
</div>
<div class="podcast-actions">
<form action="{{ url_for('podcasts.delete', podcast_id=podcast.id) }}" method="post" onsubmit="return confirm('Are you sure you want to delete this podcast?');">
<button type="submit" class="btn btn-danger">Delete Podcast</button>
</form>
<form action="{{ url_for('podcasts.update', podcast_id=podcast.id) }}" method="post">
<button type="submit" class="btn">Update Episodes</button>
</form>
</div>
<div class="podcast-meta">
<span>Last updated: {{ podcast.last_updated.strftime('%Y-%m-%d %H:%M') if podcast.last_updated else 'Never' }}</span>
<span>Last checked: {{ podcast.last_checked.strftime('%Y-%m-%d %H:%M') if podcast.last_checked else 'Never' }}</span>
<div style="display: flex; gap: 16px; margin-top: 8px; font-size: 11px;">
{% if podcast.feed_url %}
<a href="{{ podcast.feed_url }}" target="_blank" style="color: #58a6ff;">View RSS Feed</a>
{% endif %}
<a href="#" onclick="document.getElementById('naming-format-modal').style.display='block'; return false;" style="color: #58a6ff;">Configure Naming Format</a>
</div>
</div>
</div>
</div>
<div class="episodes-section">
<div class="episodes-header">
<h2>Episodes ({{ episodes|length }})</h2>
</div>
<!-- Toolbar -->
<div class="toolbar">
<span class="toolbar-btn">{{ episodes|length }} Episodes</span>
<div style="margin-left: auto;">
<form action="{{ url_for('podcasts.verify', podcast_id=podcast.id) }}" method="post" style="display: inline;">
<button type="submit" class="toolbar-btn">Verify Files</button>
</form>
<button class="toolbar-btn" onclick="window.location.reload()">Refresh</button>
</div>
</div>
{% if episodes %}
<div class="episodes-list">
<!-- Episodes Table -->
<div class="content-area">
{% if episodes %}
{# Check if any episodes have season information #}
{% set has_seasons = false %}
{% for episode in episodes %}
{% if episode.season and not has_seasons %}
{% set has_seasons = true %}
{% endif %}
{% endfor %}
{% if has_seasons %}
{# Group episodes by season #}
{% set seasons = {} %}
{% for episode in episodes %}
<div class="episode-item">
<div class="episode-header">
<h3 class="episode-title">{{ episode.title }}</h3>
<span class="episode-date">{{ episode.published_date.strftime('%Y-%m-%d') if episode.published_date else 'Unknown date' }}</span>
</div>
<div class="episode-description">
<p>{{ episode.description|truncate(200) }}</p>
</div>
<div class="episode-footer">
<div class="episode-meta">
{% if episode.duration %}
<span class="episode-duration">Duration: {{ (episode.duration / 60)|int }} min</span>
{% endif %}
{% set season_num = episode.season|default(0) %}
{% if season_num not in seasons %}
{% set seasons = seasons|merge({season_num: []}) %}
{% endif %}
{% set _ = seasons[season_num].append(episode) %}
{% endfor %}
{% if episode.episode_number %}
<span class="episode-number">Episode: {{ episode.episode_number }}</span>
{% endif %}
</div>
<div class="episode-actions">
{% if episode.downloaded %}
<span class="badge downloaded">Downloaded</span>
{# Display seasons in order #}
{% for season_num in seasons|sort %}
<div class="season-accordion">
<div class="season-header" onclick="toggleSeason('{{ season_num }}')">
<h3>
{% if season_num == 0 %}
Unsorted Episodes
{% else %}
<a href="{{ url_for('podcasts.download', episode_id=episode.id) }}" class="btn btn-sm">Download</a>
Season {{ season_num }}
{% endif %}
<a href="{{ episode.audio_url }}" target="_blank" class="btn btn-sm btn-secondary">Stream</a>
</div>
<span class="episode-count">({{ seasons[season_num]|length }} episodes)</span>
</h3>
<span id="toggle-icon-{{ season_num }}" class="toggle-icon"></span>
</div>
<div id="season-{{ season_num }}" class="season-content">
<table class="data-table">
<thead>
<tr>
<th>Episode</th>
<th style="width: 100px;">Published</th>
<th style="width: 80px;">Duration</th>
<th style="width: 80px;">Status</th>
<th style="width: 120px;">Actions</th>
</tr>
</thead>
<tbody>
{% for episode in seasons[season_num]|sort(attribute='episode_number') %}
<tr>
<td>
<div class="cell-title">
{% if episode.episode_number %}
<span style="color: #58a6ff; font-weight: bold; margin-right: 5px;">
{% if episode.season %}
S{{ episode.season }}E{{ episode.episode_number }}
{% else %}
#{{ episode.episode_number }}
{% endif %}
</span>
{% endif %}
{{ episode.title }}
{% if episode.explicit %}
<span class="explicit-badge" title="Explicit Content">E</span>
{% endif %}
</div>
{% if episode.description %}
<div class="cell-secondary" style="margin-top: 4px;">
{{ episode.description[:100] }}{% if episode.description|length > 100 %}...{% endif %}
</div>
{% endif %}
</td>
<td class="cell-center">
<div class="cell-secondary">
{{ episode.published_date.strftime('%Y-%m-%d') if episode.published_date else 'Unknown' }}
</div>
</td>
<td class="cell-center">
<div class="cell-secondary">
{% if episode.duration %}
{{ (episode.duration / 60)|int }}m
{% else %}
-
{% endif %}
</div>
</td>
<td class="cell-center">
{% if episode.downloaded %}
<span class="status-badge status-active">Downloaded</span>
{% else %}
<span class="status-badge status-pending">Available</span>
{% endif %}
</td>
<td class="cell-actions">
{% if not episode.downloaded %}
<a href="{{ url_for('podcasts.download', episode_id=episode.id) }}" class="btn btn-sm">Download</a>
{% else %}
<form action="{{ url_for('podcasts.rename_episode', episode_id=episode.id) }}" method="post" style="display: inline;">
<button type="submit" class="btn btn-sm">Rename</button>
</form>
{% endif %}
{% if episode.audio_url %}
<a href="{{ episode.audio_url }}" target="_blank" class="btn btn-sm btn-secondary" style="margin-left: 4px;">Stream</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="no-episodes">
<p>No episodes found for this podcast. Try clicking the "Update Episodes" button to fetch the latest episodes.</p>
</div>
{# Display episodes in a flat table if no season information is available #}
<table class="data-table">
<thead>
<tr>
<th>Episode</th>
<th style="width: 100px;">Published</th>
<th style="width: 80px;">Duration</th>
<th style="width: 80px;">Status</th>
<th style="width: 120px;">Actions</th>
</tr>
</thead>
<tbody>
{% for episode in episodes %}
<tr>
<td>
<div class="cell-title">
{% if episode.episode_number %}
<span style="color: #58a6ff; font-weight: bold; margin-right: 5px;">#{{ episode.episode_number }}</span>
{% endif %}
{{ episode.title }}
{% if episode.explicit %}
<span class="explicit-badge" title="Explicit Content">E</span>
{% endif %}
</div>
{% if episode.description %}
<div class="cell-secondary" style="margin-top: 4px;">
{{ episode.description[:100] }}{% if episode.description|length > 100 %}...{% endif %}
</div>
{% endif %}
</td>
<td class="cell-center">
<div class="cell-secondary">
{{ episode.published_date.strftime('%Y-%m-%d') if episode.published_date else 'Unknown' }}
</div>
</td>
<td class="cell-center">
<div class="cell-secondary">
{% if episode.duration %}
{{ (episode.duration / 60)|int }}m
{% else %}
-
{% endif %}
</div>
</td>
<td class="cell-center">
{% if episode.downloaded %}
<span class="status-badge status-active">Downloaded</span>
{% else %}
<span class="status-badge status-pending">Available</span>
{% endif %}
</td>
<td class="cell-actions">
{% if not episode.downloaded %}
<a href="{{ url_for('podcasts.download', episode_id=episode.id) }}" class="btn btn-sm">Download</a>
{% else %}
<form action="{{ url_for('podcasts.rename_episode', episode_id=episode.id) }}" method="post" style="display: inline;">
<button type="submit" class="btn btn-sm">Rename</button>
</form>
{% endif %}
{% if episode.audio_url %}
<a href="{{ episode.audio_url }}" target="_blank" class="btn btn-sm btn-secondary" style="margin-left: 4px;">Stream</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</section>
{% else %}
<div class="empty-state">
<h3>No Episodes Found</h3>
<p>No episodes found for this podcast.</p>
<form action="{{ url_for('podcasts.update', podcast_id=podcast.id) }}" method="post">
<button type="submit" class="btn btn-primary">Try Updating Episodes</button>
</form>
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
function toggleSeason(seasonId) {
const seasonContent = document.getElementById('season-' + seasonId);
const toggleIcon = document.getElementById('toggle-icon-' + seasonId);
if (seasonContent.style.display === 'block') {
seasonContent.style.display = 'none';
toggleIcon.innerHTML = '▼';
} else {
seasonContent.style.display = 'block';
toggleIcon.innerHTML = '▲';
}
}
// Open the first season by default when the page loads
document.addEventListener('DOMContentLoaded', function() {
const firstSeasonAccordion = document.querySelector('.season-accordion');
if (firstSeasonAccordion) {
const seasonId = firstSeasonAccordion.querySelector('.season-content').id.replace('season-', '');
toggleSeason(seasonId);
}
});
</script>
{% include 'podcasts/naming_format_modal.html' %}
{% endblock %}