Initial commit
This commit is contained in:
commit
e86ab53de5
35 changed files with 2638 additions and 0 deletions
56
app/web/app.py
Normal file
56
app/web/app.py
Normal 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
237
app/web/routes/api.py
Normal 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
38
app/web/routes/main.py
Normal 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
140
app/web/routes/podcasts.py
Normal 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))
|
88
app/web/routes/settings.py
Normal file
88
app/web/routes/settings.py
Normal 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)}
|
Loading…
Add table
Add a link
Reference in a new issue