Updates
This commit is contained in:
parent
e86ab53de5
commit
095bf52a2f
29 changed files with 2494 additions and 758 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
/instance.zip
|
||||
/instance/podcastrr.db
|
||||
/podcastrr.db
|
||||
/.venv/
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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():
|
||||
|
|
200
app/services/task_manager.py
Normal file
200
app/services/task_manager.py
Normal 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()
|
|
@ -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
98
app/web/routes/debug.py
Normal 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
|
|
@ -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)
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
51
app/web/routes/tasks.py
Normal 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
|
||||
})
|
|
@ -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
|
|
@ -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
39
main.py
|
@ -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
3
migrations/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
Migrations package for Podcastrr.
|
||||
"""
|
33
migrations/add_episode_ordering.py
Normal file
33
migrations/add_episode_ordering.py
Normal 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
|
47
migrations/add_season_explicit_naming_format.py
Normal file
47
migrations/add_season_explicit_naming_format.py
Normal 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
|
@ -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>© 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>
|
||||
|
|
|
@ -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
138
templates/debug/logs.html
Normal 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 %}
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
81
templates/podcasts/naming_format_modal.html
Normal file
81
templates/podcasts/naming_format_modal.html
Normal 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;">×</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>
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue