Initial commit

This commit is contained in:
Cody Cook 2025-06-14 22:53:38 -07:00
commit e86ab53de5
35 changed files with 2638 additions and 0 deletions

3
app/models/__init__.py Normal file
View file

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

26
app/models/database.py Normal file
View file

@ -0,0 +1,26 @@
"""
Database configuration for Podcastrr.
"""
import sqlalchemy as sa
import sqlalchemy.orm as sa_orm
# Add compatibility for older SQLAlchemy versions
if not hasattr(sa_orm, 'DeclarativeBase'):
# Create a class that mimics DeclarativeBase for SQLAlchemy 1.4
class DeclarativeBase:
pass
# Monkey-patch sqlalchemy.orm
sa_orm.DeclarativeBase = DeclarativeBase
if not hasattr(sa_orm, 'DeclarativeBaseNoMeta'):
# Create a class that mimics DeclarativeBaseNoMeta for SQLAlchemy 1.4
class DeclarativeBaseNoMeta:
pass
# Monkey-patch sqlalchemy.orm
sa_orm.DeclarativeBaseNoMeta = DeclarativeBaseNoMeta
# Now import Flask-SQLAlchemy
from flask_sqlalchemy import SQLAlchemy
# Create SQLAlchemy instance
db = SQLAlchemy()

89
app/models/podcast.py Normal file
View file

@ -0,0 +1,89 @@
"""
Podcast and Episode models for Podcastrr.
"""
from datetime import datetime
from app.models.database import db
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))
description = db.Column(db.Text)
image_url = db.Column(db.String(512))
feed_url = db.Column(db.String(512), nullable=False, unique=True)
external_id = db.Column(db.String(255), unique=True)
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)
# 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.
"""
return {
'id': self.id,
'title': self.title,
'author': self.author,
'description': self.description,
'image_url': self.image_url,
'feed_url': self.feed_url,
'external_id': self.external_id,
'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,
'episode_count': self.episodes.count()
}
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)
description = db.Column(db.Text)
audio_url = db.Column(db.String(512), nullable=False)
image_url = db.Column(db.String(512))
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))
guid = db.Column(db.String(512), unique=True)
downloaded = db.Column(db.Boolean, default=False)
file_path = db.Column(db.String(512))
def __repr__(self):
return f'<Episode {self.title}>'
def to_dict(self):
"""
Convert episode to dictionary for API responses.
"""
return {
'id': self.id,
'podcast_id': self.podcast_id,
'title': self.title,
'description': self.description,
'audio_url': self.audio_url,
'image_url': self.image_url,
'published_date': self.published_date.isoformat() if self.published_date else None,
'duration': self.duration,
'file_size': self.file_size,
'episode_number': self.episode_number,
'guid': self.guid,
'downloaded': self.downloaded,
'file_path': self.file_path
}

33
app/models/settings.py Normal file
View file

@ -0,0 +1,33 @@
"""
Settings model for Podcastrr.
"""
from app.models.database import db
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.
"""
return {
'id': self.id,
'download_path': self.download_path,
'naming_format': self.naming_format,
'auto_download': self.auto_download,
'max_downloads': self.max_downloads,
'delete_after_days': self.delete_after_days
}

3
app/services/__init__.py Normal file
View file

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

View file

@ -0,0 +1,179 @@
"""
Podcast downloader service for Podcastrr.
"""
import os
import requests
import logging
from datetime import datetime, timedelta
from flask import current_app
from app.models.database import db
from app.models.settings import Settings
# Set up logging
logger = logging.getLogger(__name__)
def download_episode(episode):
"""
Download a podcast episode.
Args:
episode: Episode model instance.
Returns:
str: Path to the downloaded file.
"""
if not episode.audio_url:
raise ValueError("Episode has no audio URL")
# Get settings
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()
# Create download directory
download_path = settings.download_path
os.makedirs(download_path, exist_ok=True)
# Format filename using the naming format
podcast = episode.podcast
filename = format_filename(settings.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)
# Download the file
try:
response = requests.get(episode.audio_url, stream=True)
response.raise_for_status()
# Get content type and set appropriate extension
content_type = response.headers.get('Content-Type', '')
if 'mp3' in content_type:
file_path += '.mp3'
elif 'mpeg' in content_type:
file_path += '.mp3'
elif 'mp4' in content_type or 'm4a' in content_type:
file_path += '.m4a'
elif 'ogg' in content_type:
file_path += '.ogg'
elif 'wav' in content_type:
file_path += '.wav'
else:
file_path += '.mp3' # Default to mp3
# Write the file
with open(file_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
# Update episode in database
episode.downloaded = True
episode.file_path = file_path
db.session.commit()
logger.info(f"Downloaded episode: {episode.title}")
return file_path
except Exception as e:
logger.error(f"Error downloading episode: {str(e)}")
raise
def format_filename(format_string, podcast, episode):
"""
Format a filename using the provided format string and podcast/episode data.
Args:
format_string (str): Format string with placeholders.
podcast: Podcast model instance.
episode: Episode model instance.
Returns:
str: Formatted filename.
"""
# 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 '',
'published_date': episode.published_date.strftime('%Y-%m-%d') if episode.published_date else '',
'author': sanitize_filename(podcast.author) if podcast.author else ''
}
# Format the string
try:
return 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']}"
def sanitize_filename(filename):
"""
Sanitize a string to be used as a filename.
Args:
filename (str): Original filename.
Returns:
str: Sanitized filename.
"""
# Replace invalid characters
invalid_chars = ['<', '>', ':', '"', '/', '\\', '|', '?', '*']
for char in invalid_chars:
filename = filename.replace(char, '_')
# Limit length
if len(filename) > 100:
filename = filename[:97] + '...'
return filename
def delete_old_episodes(days=30):
"""
Delete episodes older than the specified number of days.
Args:
days (int): Number of days to keep episodes.
Returns:
int: Number of episodes deleted.
"""
from app.models.podcast import Episode
settings = Settings.query.first()
if settings:
days = settings.delete_after_days
# Calculate the cutoff date
cutoff_date = datetime.utcnow() - timedelta(days=days)
# Find episodes to delete
episodes = Episode.query.filter(
Episode.downloaded == True,
Episode.published_date < cutoff_date
).all()
count = 0
for episode in episodes:
if episode.file_path and os.path.exists(episode.file_path):
try:
os.remove(episode.file_path)
episode.file_path = None
episode.downloaded = False
count += 1
except Exception as e:
logger.error(f"Error deleting episode file: {str(e)}")
db.session.commit()
logger.info(f"Deleted {count} old episodes")
return count

View file

@ -0,0 +1,317 @@
"""
Podcast search service for Podcastrr.
"""
import requests
import feedparser
from datetime import datetime
import logging
# Set up logging
logger = logging.getLogger(__name__)
def search_podcasts(query=None, podcast_id=None):
"""
Search for podcasts using the iTunes API.
Args:
query (str): Search query for podcasts.
podcast_id (str): iTunes podcast ID to get specific podcast.
Returns:
list: List of podcast dictionaries if query is provided.
dict: Podcast dictionary if podcast_id is provided.
"""
if not query and not podcast_id:
return [] if query is not None else None
try:
if podcast_id:
# Get specific podcast by ID
url = f"https://itunes.apple.com/lookup?id={podcast_id}&entity=podcast"
response = requests.get(url)
data = response.json()
if data['resultCount'] == 0:
return None
podcast = data['results'][0]
return _format_podcast(podcast)
else:
# Search for podcasts
url = f"https://itunes.apple.com/search?term={query}&entity=podcast&limit=20"
response = requests.get(url)
data = response.json()
results = []
for podcast in data['results']:
results.append(_format_podcast(podcast))
return results
except Exception as e:
logger.error(f"Error searching podcasts: {str(e)}")
return [] if query is not None else None
def _format_podcast(podcast):
"""
Format podcast data from iTunes API.
Args:
podcast (dict): Podcast data from iTunes API.
Returns:
dict: Formatted podcast data.
"""
feed_url = podcast.get('feedUrl', '')
# Log feed URL for debugging
logger.info(f"Podcast: {podcast.get('collectionName', '')}, Feed URL: {feed_url}")
if not feed_url:
logger.warning(f"No feed URL found for podcast: {podcast.get('collectionName', '')}")
return {
'title': podcast.get('collectionName', ''),
'author': podcast.get('artistName', ''),
'description': podcast.get('description', ''),
'image_url': podcast.get('artworkUrl600', podcast.get('artworkUrl100', '')),
'feed_url': feed_url,
'external_id': str(podcast.get('collectionId', '')),
'genre': podcast.get('primaryGenreName', ''),
'country': podcast.get('country', '')
}
def get_podcast_episodes(feed_url):
"""
Get podcast episodes from RSS feed.
Args:
feed_url (str): URL of the podcast RSS feed.
Returns:
list: List of episode dictionaries.
"""
try:
if not feed_url:
logger.error("Empty feed URL provided")
return []
logger.info(f"Fetching episodes from feed: {feed_url}")
# Check if the feed URL is valid and follow redirects
try:
import requests
response = requests.head(feed_url, allow_redirects=True, timeout=10)
if response.status_code != 200:
logger.error(f"Feed URL returned status code {response.status_code}: {feed_url}")
if response.url != feed_url:
logger.info(f"Feed URL redirected from {feed_url} to {response.url}")
feed_url = response.url
except Exception as e:
logger.warning(f"Error checking feed URL: {str(e)}")
# Parse the feed
feed = feedparser.parse(feed_url)
# Check for parsing errors
if hasattr(feed, 'bozo_exception') and feed.bozo_exception:
logger.error(f"Error parsing feed: {feed.bozo_exception}")
# Try to parse the feed with requests if feedparser fails
if len(feed.entries) == 0:
try:
logger.info("Trying alternative method to fetch feed")
response = requests.get(feed_url, timeout=10)
feed = feedparser.parse(response.content)
logger.info(f"Alternative method found {len(feed.entries)} entries")
except Exception as e:
logger.error(f"Alternative method also failed: {str(e)}")
logger.info(f"Found {len(feed.entries)} entries in feed")
episodes = []
for entry in feed.entries:
# Log entry details for debugging
logger.debug(f"Processing entry: {entry.get('title', 'No title')}")
# Extract basic episode info
episode = {
'title': entry.get('title', ''),
'description': entry.get('description', ''),
'published_date': _parse_date(entry.get('published')),
'guid': entry.get('id', ''),
'duration': _parse_duration(entry.get('itunes_duration', '')),
'episode_number': entry.get('itunes_episode', '')
}
# Generate a GUID if one is not provided
if not episode['guid']:
# Try to use a link as GUID
for link in entry.get('links', []):
if link.get('rel') == 'alternate' or link.get('type') == 'text/html':
episode['guid'] = link.get('href', '')
logger.debug(f"Generated GUID from link: {episode['guid']}")
break
# If still no GUID, generate one from title and date
if not episode['guid'] and episode['title']:
import hashlib
# Create a hash from the title and published date (if available)
hash_input = episode['title']
if episode['published_date']:
hash_input += episode['published_date'].isoformat()
episode['guid'] = hashlib.md5(hash_input.encode('utf-8')).hexdigest()
logger.debug(f"Generated GUID from title and date: {episode['guid']}")
# If still no GUID (no title), skip this episode
if not episode['guid']:
logger.warning("Could not generate GUID for episode, skipping")
continue
# Get audio URL
audio_found = False
# Method 1: Check links
for link in entry.get('links', []):
if link.get('type', '').startswith('audio/'):
episode['audio_url'] = link.get('href', '')
episode['file_size'] = link.get('length', 0)
audio_found = True
logger.debug(f"Found audio URL in links: {episode['audio_url']}")
break
# Method 2: Check enclosures
if not audio_found and hasattr(entry, 'enclosures') and entry.enclosures:
for enclosure in entry.enclosures:
if enclosure.get('type', '').startswith('audio/'):
episode['audio_url'] = enclosure.get('href', '')
episode['file_size'] = enclosure.get('length', 0)
audio_found = True
logger.debug(f"Found audio URL in enclosure: {episode['audio_url']}")
break
# Method 3: Check media:content
if not audio_found and hasattr(entry, 'media_content'):
for media in entry.media_content:
if media.get('type', '').startswith('audio/'):
episode['audio_url'] = media.get('url', '')
episode['file_size'] = media.get('fileSize', 0)
audio_found = True
logger.debug(f"Found audio URL in media:content: {episode['audio_url']}")
break
# Method 4: Check for generic enclosure
if not audio_found and hasattr(entry, 'enclosures') and entry.enclosures:
# Try any enclosure if we haven't found an audio URL yet
enclosure = entry.enclosures[0]
episode['audio_url'] = enclosure.get('href', '')
episode['file_size'] = enclosure.get('length', 0)
audio_found = True
logger.debug(f"Found audio URL in generic enclosure: {episode['audio_url']}")
if not audio_found:
logger.warning(f"No audio URL found for episode: {episode['title']}")
# Get image URL
if 'image' in entry and 'href' in entry.image:
episode['image_url'] = entry.image.href
# Only add episodes with audio URLs
if audio_found and 'audio_url' in episode and episode['audio_url']:
# Validate the audio URL
try:
# Check if the URL is valid
if not episode['audio_url'].startswith(('http://', 'https://')):
logger.warning(f"Invalid audio URL format: {episode['audio_url']}")
continue
# Try to validate the URL without downloading the file
import requests
head_response = requests.head(episode['audio_url'], timeout=5, allow_redirects=True)
# Check if the URL is accessible
if head_response.status_code >= 400:
logger.warning(f"Audio URL returned status code {head_response.status_code}: {episode['audio_url']}")
continue
# Check if the content type is audio
content_type = head_response.headers.get('Content-Type', '')
if not content_type.startswith('audio/') and 'application/octet-stream' not in content_type:
logger.warning(f"Audio URL has non-audio content type: {content_type}")
# Don't skip here as some servers might not report the correct content type
# If we got here, the audio URL is probably valid
episodes.append(episode)
logger.debug(f"Added episode with valid audio URL: {episode['title']}")
except Exception as e:
# If we can't validate the URL, still add the episode but log a warning
logger.warning(f"Could not validate audio URL: {str(e)}")
episodes.append(episode)
logger.debug(f"Added episode with unvalidated audio URL: {episode['title']}")
else:
logger.warning(f"Skipping episode without audio URL: {episode['title']}")
logger.info(f"Processed {len(episodes)} valid episodes")
return episodes
except Exception as e:
logger.error(f"Error getting podcast episodes: {str(e)}")
return []
def _parse_date(date_str):
"""
Parse date string to datetime object.
Args:
date_str (str): Date string from RSS feed.
Returns:
datetime: Parsed datetime object or None.
"""
if not date_str:
return None
try:
return datetime.strptime(date_str, '%a, %d %b %Y %H:%M:%S %z')
except ValueError:
try:
return datetime.strptime(date_str, '%a, %d %b %Y %H:%M:%S %Z')
except ValueError:
try:
return datetime.strptime(date_str, '%Y-%m-%dT%H:%M:%S%z')
except ValueError:
logger.warning(f"Could not parse date: {date_str}")
return None
def _parse_duration(duration_str):
"""
Parse duration string to seconds.
Args:
duration_str (str): Duration string from RSS feed.
Returns:
int: Duration in seconds or None.
"""
if not duration_str:
return None
try:
# Try to parse as seconds
return int(duration_str)
except ValueError:
try:
# Try to parse as HH:MM:SS
parts = duration_str.split(':')
if len(parts) == 3:
hours, minutes, seconds = parts
return int(hours) * 3600 + int(minutes) * 60 + int(seconds)
elif len(parts) == 2:
minutes, seconds = parts
return int(minutes) * 60 + int(seconds)
else:
return None
except ValueError:
logger.warning(f"Could not parse duration: {duration_str}")
return None

View file

@ -0,0 +1,184 @@
"""
Podcast updater service for Podcastrr.
"""
import logging
from datetime import datetime, timedelta
from flask import current_app
from app.models.database import db
from app.models.podcast import Podcast, Episode
from app.models.settings import Settings
from app.services.podcast_search import get_podcast_episodes
from app.services.podcast_downloader import download_episode
# Set up logging
logger = logging.getLogger(__name__)
def update_all_podcasts():
"""
Update all podcasts in the database.
Returns:
dict: Statistics about the update process.
"""
podcasts = Podcast.query.all()
stats = {
'podcasts_updated': 0,
'new_episodes': 0,
'episodes_downloaded': 0,
'errors': 0
}
for podcast in podcasts:
try:
result = update_podcast(podcast.id)
stats['podcasts_updated'] += 1
stats['new_episodes'] += result['new_episodes']
stats['episodes_downloaded'] += result['episodes_downloaded']
except Exception as e:
logger.error(f"Error updating podcast {podcast.title}: {str(e)}")
stats['errors'] += 1
return stats
def update_podcast(podcast_id):
"""
Update a specific podcast.
Args:
podcast_id (int): ID of the podcast to update.
Returns:
dict: Statistics about the update process.
"""
podcast = Podcast.query.get_or_404(podcast_id)
stats = {
'new_episodes': 0,
'episodes_downloaded': 0,
'feed_status': 'success'
}
try:
logger.info(f"Updating podcast: {podcast.title} (ID: {podcast.id})")
logger.info(f"Feed URL: {podcast.feed_url}")
# Get episodes from feed
episodes = get_podcast_episodes(podcast.feed_url)
# Update podcast last_checked timestamp
podcast.last_checked = datetime.utcnow()
if not episodes:
logger.warning(f"No episodes found for podcast: {podcast.title}")
stats['feed_status'] = 'no_episodes'
# Check if we need to refresh the feed URL from iTunes
if podcast.external_id:
try:
from app.services.podcast_search import search_podcasts
logger.info(f"Trying to refresh feed URL from iTunes for podcast ID: {podcast.external_id}")
podcast_data = search_podcasts(podcast_id=podcast.external_id)
if podcast_data and podcast_data.get('feed_url') and podcast_data['feed_url'] != podcast.feed_url:
logger.info(f"Updated feed URL from {podcast.feed_url} to {podcast_data['feed_url']}")
podcast.feed_url = podcast_data['feed_url']
db.session.commit()
# Try again with the new feed URL
episodes = get_podcast_episodes(podcast.feed_url)
logger.info(f"Found {len(episodes)} episodes with updated feed URL")
except Exception as e:
logger.error(f"Error refreshing feed URL: {str(e)}")
# Process each episode
for episode_data in episodes:
# Skip episodes without required fields
if not episode_data.get('guid'):
logger.warning(f"Skipping episode without GUID: {episode_data.get('title', 'Unknown')}")
continue
if not episode_data.get('audio_url'):
logger.warning(f"Skipping episode without audio URL: {episode_data.get('title', 'Unknown')}")
continue
# Check if episode already exists
existing = Episode.query.filter_by(guid=episode_data['guid']).first()
if not existing:
# Create new episode
try:
episode = Episode(
podcast_id=podcast.id,
title=episode_data.get('title', ''),
description=episode_data.get('description', ''),
audio_url=episode_data.get('audio_url', ''),
image_url=episode_data.get('image_url', podcast.image_url), # Use podcast image if episode has none
published_date=episode_data.get('published_date'),
duration=episode_data.get('duration'),
file_size=episode_data.get('file_size'),
episode_number=episode_data.get('episode_number'),
guid=episode_data['guid'],
downloaded=False
)
db.session.add(episode)
stats['new_episodes'] += 1
logger.info(f"Added new episode: {episode.title}")
# Auto-download if enabled
if podcast.auto_download and episode.audio_url:
try:
download_episode(episode)
stats['episodes_downloaded'] += 1
logger.info(f"Auto-downloaded episode: {episode.title}")
except Exception as e:
logger.error(f"Error auto-downloading episode {episode.title}: {str(e)}")
except Exception as e:
logger.error(f"Error adding episode: {str(e)}")
# Update podcast last_updated timestamp if new episodes were found
if stats['new_episodes'] > 0:
podcast.last_updated = datetime.utcnow()
db.session.commit()
logger.info(f"Podcast update completed: {stats}")
return stats
except Exception as e:
db.session.rollback()
logger.error(f"Error updating podcast {podcast.title}: {str(e)}")
stats['feed_status'] = 'error'
stats['error'] = str(e)
raise
def schedule_updates():
"""
Schedule podcast updates based on settings.
This function is meant to be called by a scheduler (e.g., APScheduler).
"""
logger.info("Starting scheduled podcast updates")
try:
stats = update_all_podcasts()
logger.info(f"Scheduled update completed: {stats}")
except Exception as e:
logger.error(f"Error during scheduled update: {str(e)}")
def clean_old_downloads():
"""
Clean up old downloaded episodes.
This function is meant to be called by a scheduler (e.g., APScheduler).
"""
from app.services.podcast_downloader import delete_old_episodes
logger.info("Starting cleanup of old downloads")
try:
count = delete_old_episodes()
logger.info(f"Deleted {count} old episodes")
except Exception as e:
logger.error(f"Error during cleanup: {str(e)}")

56
app/web/app.py Normal file
View file

@ -0,0 +1,56 @@
"""
Flask application factory for Podcastrr.
"""
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')
# Load default configuration
app.config.from_mapping(
SECRET_KEY=os.environ.get('SECRET_KEY', 'dev'),
SQLALCHEMY_DATABASE_URI=os.environ.get('DATABASE_URI', 'sqlite:///podcastrr.db'),
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
db.init_app(app)
Migrate(app, db)
# Register blueprints
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')
# Ensure the download directory exists
os.makedirs(app.config['DOWNLOAD_PATH'], exist_ok=True)
return app

237
app/web/routes/api.py Normal file
View file

@ -0,0 +1,237 @@
"""
API routes for the Podcastrr application.
"""
from flask import Blueprint, jsonify, request, current_app
from app.models.podcast import Podcast, Episode
from app.models.database import db
from app.services.podcast_search import search_podcasts
from app.services.podcast_downloader import download_episode
import os
api_bp = Blueprint('api', __name__)
# Podcasts API
@api_bp.route('/podcasts', methods=['GET'])
def get_podcasts():
"""
Get all tracked podcasts.
"""
podcasts = Podcast.query.all()
return jsonify({
'podcasts': [podcast.to_dict() for podcast in podcasts]
})
@api_bp.route('/podcasts/<int:podcast_id>', methods=['GET'])
def get_podcast(podcast_id):
"""
Get a specific podcast.
"""
podcast = Podcast.query.get_or_404(podcast_id)
return jsonify(podcast.to_dict())
@api_bp.route('/podcasts/search', methods=['GET'])
def search_api():
"""
Search for podcasts.
"""
query = request.args.get('q', '')
if not query:
return jsonify({'error': 'Query parameter is required'}), 400
results = search_podcasts(query)
return jsonify({'results': results})
@api_bp.route('/podcasts', methods=['POST'])
def add_podcast():
"""
Add a podcast to track.
"""
data = request.json
if not data or 'podcast_id' not in data:
return jsonify({'error': 'podcast_id is required'}), 400
# Check if podcast already exists
existing = Podcast.query.filter_by(external_id=data['podcast_id']).first()
if existing:
return jsonify({'error': 'Podcast is already being tracked'}), 409
# Get podcast details from service
podcast_data = search_podcasts(podcast_id=data['podcast_id'])
if not podcast_data:
return jsonify({'error': 'Failed to find podcast'}), 404
# Check if feed URL is valid
import logging
logger = logging.getLogger(__name__)
if not podcast_data.get('feed_url'):
logger.error(f"No feed URL found for podcast ID: {data['podcast_id']}")
return jsonify({'error': 'Podcast has no valid RSS feed URL'}), 400
logger.info(f"Adding podcast: {podcast_data['title']} with feed URL: {podcast_data['feed_url']}")
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=data['podcast_id']
)
db.session.add(podcast)
db.session.commit()
# Fetch and add episodes for the podcast
from app.services.podcast_search import get_podcast_episodes
import logging
logger = logging.getLogger(__name__)
logger.info(f"Fetching episodes for podcast: {podcast.title} (ID: {podcast.id})")
logger.info(f"Feed URL: {podcast.feed_url}")
episodes_data = get_podcast_episodes(podcast.feed_url)
logger.info(f"Found {len(episodes_data)} episodes in feed")
episodes_added = 0
for episode_data in episodes_data:
# Check if episode has required fields
if not episode_data.get('guid'):
logger.warning(f"Skipping episode without GUID: {episode_data.get('title', 'Unknown')}")
continue
if not episode_data.get('audio_url'):
logger.warning(f"Skipping episode without audio URL: {episode_data.get('title', 'Unknown')}")
continue
# Check if episode already exists by GUID
existing = Episode.query.filter_by(guid=episode_data.get('guid')).first()
if existing:
logger.debug(f"Episode already exists: {episode_data.get('title', 'Unknown')}")
continue
# Create new episode
try:
episode = Episode(
podcast_id=podcast.id,
title=episode_data.get('title', ''),
description=episode_data.get('description', ''),
audio_url=episode_data.get('audio_url', ''),
image_url=episode_data.get('image_url', podcast.image_url), # Use podcast image if episode has none
published_date=episode_data.get('published_date'),
duration=episode_data.get('duration'),
file_size=episode_data.get('file_size'),
episode_number=episode_data.get('episode_number', ''),
guid=episode_data.get('guid', '')
)
db.session.add(episode)
episodes_added += 1
logger.debug(f"Added episode: {episode.title}")
except Exception as e:
logger.error(f"Error adding episode: {str(e)}")
db.session.commit()
logger.info(f"Added {episodes_added} new episodes for podcast: {podcast.title}")
# If no episodes were added, try updating the podcast
if episodes_added == 0 and len(episodes_data) == 0:
logger.warning(f"No episodes found for podcast: {podcast.title}. Trying to update...")
try:
from app.services.podcast_updater import update_podcast
stats = update_podcast(podcast.id)
logger.info(f"Podcast update completed: {stats}")
except Exception as e:
logger.error(f"Error updating podcast: {str(e)}")
return jsonify(podcast.to_dict()), 201
@api_bp.route('/podcasts/<int:podcast_id>', methods=['DELETE'])
def delete_podcast(podcast_id):
"""
Delete a podcast from tracking.
"""
podcast = Podcast.query.get_or_404(podcast_id)
# Delete associated episodes
Episode.query.filter_by(podcast_id=podcast_id).delete()
db.session.delete(podcast)
db.session.commit()
return jsonify({'message': f'Podcast "{podcast.title}" has been deleted'})
# Podcast update API
@api_bp.route('/podcasts/<int:podcast_id>/update', methods=['POST'])
def update_podcast_api(podcast_id):
"""
Update a podcast to fetch new episodes.
"""
from app.services.podcast_updater import update_podcast
try:
stats = update_podcast(podcast_id)
return jsonify({
'message': f'Podcast updated successfully. {stats["new_episodes"]} new episodes found.',
'stats': stats
})
except Exception as e:
return jsonify({'error': str(e)}), 500
# Episodes API
@api_bp.route('/podcasts/<int:podcast_id>/episodes', methods=['GET'])
def get_episodes(podcast_id):
"""
Get all episodes for a podcast.
"""
Podcast.query.get_or_404(podcast_id) # Ensure podcast exists
episodes = Episode.query.filter_by(podcast_id=podcast_id).order_by(Episode.published_date.desc()).all()
return jsonify({
'episodes': [episode.to_dict() for episode in episodes]
})
@api_bp.route('/episodes/<int:episode_id>', methods=['GET'])
def get_episode(episode_id):
"""
Get a specific episode.
"""
episode = Episode.query.get_or_404(episode_id)
return jsonify(episode.to_dict())
@api_bp.route('/episodes/<int:episode_id>/download', methods=['POST'])
def download_episode_api(episode_id):
"""
Download an episode.
"""
episode = Episode.query.get_or_404(episode_id)
try:
download_path = download_episode(episode)
return jsonify({
'message': 'Episode downloaded successfully',
'path': download_path
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@api_bp.route('/episodes/<int:episode_id>', methods=['DELETE'])
def delete_episode(episode_id):
"""
Delete a downloaded episode.
"""
episode = Episode.query.get_or_404(episode_id)
if episode.file_path and os.path.exists(episode.file_path):
try:
os.remove(episode.file_path)
episode.file_path = None
episode.downloaded = False
db.session.commit()
return jsonify({'message': 'Episode deleted successfully'})
except Exception as e:
return jsonify({'error': str(e)}), 500
else:
return jsonify({'error': 'Episode file not found'}), 404

38
app/web/routes/main.py Normal file
View file

@ -0,0 +1,38 @@
"""
Main routes for the Podcastrr application.
"""
from flask import Blueprint, render_template, current_app
from app.models.podcast import Podcast
main_bp = Blueprint('main', __name__)
@main_bp.route('/')
def index():
"""
Render the home page.
"""
# 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)
@main_bp.route('/about')
def about():
"""
Render the about page.
"""
return render_template('about.html', title='About')
@main_bp.route('/dashboard')
def dashboard():
"""
Render the dashboard page.
"""
# Get statistics
total_podcasts = Podcast.query.count()
return render_template('dashboard.html',
title='Dashboard',
total_podcasts=total_podcasts)

140
app/web/routes/podcasts.py Normal file
View file

@ -0,0 +1,140 @@
"""
Podcast routes for the Podcastrr application.
"""
import logging
logger = logging.getLogger(__name__)
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app
from app.models.podcast import Podcast, Episode
from app.models.database import db
from app.services.podcast_search import search_podcasts
from app.services.podcast_downloader import download_episode
podcasts_bp = Blueprint('podcasts', __name__)
@podcasts_bp.route('/')
def index():
"""
Display all tracked podcasts.
"""
podcasts = Podcast.query.all()
return render_template('podcasts/index.html',
title='Podcasts',
podcasts=podcasts)
@podcasts_bp.route('/search', methods=['GET', 'POST'])
def search():
"""
Search for podcasts.
"""
results = []
if request.method == 'POST':
query = request.form.get('query', '')
if query:
results = search_podcasts(query)
return render_template('podcasts/search.html',
title='Search Podcasts',
results=results)
@podcasts_bp.route('/add/<string:podcast_id>', methods=['POST'])
def add(podcast_id):
"""
Add a podcast to track.
"""
# Check if podcast already exists
existing = Podcast.query.filter_by(external_id=podcast_id).first()
if existing:
flash('Podcast is already being tracked.', 'info')
else:
# Get podcast details from service
podcast_data = search_podcasts(podcast_id=podcast_id)
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'))
@podcasts_bp.route('/<int:podcast_id>')
def view(podcast_id):
"""
View a specific podcast and its episodes.
"""
podcast = Podcast.query.get_or_404(podcast_id)
episodes = Episode.query.filter_by(podcast_id=podcast_id).order_by(Episode.published_date.desc()).all()
return render_template('podcasts/view.html',
title=podcast.title,
podcast=podcast,
episodes=episodes)
@podcasts_bp.route('/delete/<int:podcast_id>', methods=['POST'])
def delete(podcast_id):
"""
Delete a podcast from tracking.
"""
podcast = Podcast.query.get_or_404(podcast_id)
# Delete associated episodes
Episode.query.filter_by(podcast_id=podcast_id).delete()
db.session.delete(podcast)
db.session.commit()
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.
"""
episode = Episode.query.get_or_404(episode_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')
return redirect(url_for('podcasts.view', podcast_id=episode.podcast_id))
@podcasts_bp.route('/update/<int:podcast_id>', methods=['POST'])
def update(podcast_id):
"""
Manually update a podcast to fetch new episodes.
"""
podcast = Podcast.query.get_or_404(podcast_id)
try:
from app.services.podcast_updater import update_podcast
logger.info(f"Manually updating podcast: {podcast.title} (ID: {podcast.id})")
stats = update_podcast(podcast_id)
if stats['new_episodes'] > 0:
flash(f"Found {stats['new_episodes']} new episodes!", 'success')
else:
flash("No new episodes found.", 'info')
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')
return redirect(url_for('podcasts.view', podcast_id=podcast_id))

View file

@ -0,0 +1,88 @@
"""
Settings routes for the Podcastrr application.
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app
import os
from app.models.settings import Settings
from app.models.database import db
settings_bp = Blueprint('settings', __name__)
@settings_bp.route('/', methods=['GET', 'POST'])
def index():
"""
Display and update application settings.
"""
# Get current settings
settings = Settings.query.first()
# If no settings exist, create default settings
if not settings:
settings = Settings(
download_path=current_app.config['DOWNLOAD_PATH'],
naming_format="{podcast_title}/{episode_title}",
auto_download=False,
max_downloads=5,
delete_after_days=30
)
db.session.add(settings)
db.session.commit()
if request.method == 'POST':
# Update settings
download_path = request.form.get('download_path')
naming_format = request.form.get('naming_format')
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:
os.makedirs(download_path, exist_ok=True)
except Exception as e:
flash(f'Error creating download directory: {str(e)}', 'error')
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)
@settings_bp.route('/naming-preview', methods=['POST'])
def naming_preview():
"""
Preview the naming format.
"""
naming_format = request.form.get('naming_format', '')
# Example data for preview
example_data = {
'podcast_title': 'Example Podcast',
'episode_title': 'Episode 1: Introduction',
'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)}