Initial commit
This commit is contained in:
commit
e86ab53de5
35 changed files with 2638 additions and 0 deletions
15
.env.example
Normal file
15
.env.example
Normal file
|
@ -0,0 +1,15 @@
|
|||
# Flask configuration
|
||||
FLASK_ENV=development
|
||||
SECRET_KEY=your_secret_key_here
|
||||
|
||||
# Database configuration
|
||||
DATABASE_URI=sqlite:///podcastrr.db
|
||||
|
||||
# Application configuration
|
||||
DOWNLOAD_PATH=C:\path\to\downloads
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# API Keys (if needed)
|
||||
# ITUNES_API_KEY=your_itunes_api_key
|
||||
# SPOTIFY_CLIENT_ID=your_spotify_client_id
|
||||
# SPOTIFY_CLIENT_SECRET=your_spotify_client_secret
|
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
10
.idea/Podcastrr.iml
generated
Normal file
10
.idea/Podcastrr.iml
generated
Normal file
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="Python 3.12 (Podcastrr)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
|
@ -0,0 +1,6 @@
|
|||
<component name="InspectionProjectProfileManager">
|
||||
<settings>
|
||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||
<version value="1.0" />
|
||||
</settings>
|
||||
</component>
|
6
.idea/misc.xml
generated
Normal file
6
.idea/misc.xml
generated
Normal file
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Black">
|
||||
<option name="sdkName" value="Python 3.12 (Podcastrr)" />
|
||||
</component>
|
||||
</project>
|
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/Podcastrr.iml" filepath="$PROJECT_DIR$/.idea/Podcastrr.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
83
README.md
Normal file
83
README.md
Normal file
|
@ -0,0 +1,83 @@
|
|||
# Podcastrr
|
||||
|
||||
A podcast management application similar to Sonarr but for podcasts, built with Python and Flask.
|
||||
|
||||
## Features
|
||||
|
||||
- **Search for Podcasts**: Find podcasts from various sources
|
||||
- **Track Podcasts**: Monitor your favorite podcasts for new episodes
|
||||
- **Download Management**: Automatically download new episodes and manage storage
|
||||
- **Custom Naming**: Configure how downloaded files are named
|
||||
- **Web Interface**: Manage everything through an intuitive web interface
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.8+
|
||||
- Dependencies listed in `requirements.txt`
|
||||
|
||||
## Installation
|
||||
|
||||
1. Clone the repository:
|
||||
```
|
||||
git clone https://github.com/yourusername/podcastrr.git
|
||||
cd podcastrr
|
||||
```
|
||||
|
||||
2. Create and activate a virtual environment:
|
||||
```
|
||||
python -m venv .venv
|
||||
# On Windows
|
||||
.venv\Scripts\activate
|
||||
# On macOS/Linux
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
3. Install dependencies:
|
||||
```
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
4. Create a `.env` file in the root directory with the following variables:
|
||||
```
|
||||
FLASK_ENV=development
|
||||
SECRET_KEY=your_secret_key
|
||||
DATABASE_URI=sqlite:///podcastrr.db
|
||||
DOWNLOAD_PATH=/path/to/downloads
|
||||
```
|
||||
|
||||
5. Initialize the database:
|
||||
```
|
||||
flask db init
|
||||
flask db migrate
|
||||
flask db upgrade
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Run the application:
|
||||
```
|
||||
python main.py
|
||||
```
|
||||
|
||||
Then open your browser and navigate to `http://localhost:5000`.
|
||||
|
||||
## Development
|
||||
|
||||
1. Install development dependencies:
|
||||
```
|
||||
pip install -r requirements-dev.txt
|
||||
```
|
||||
|
||||
2. Run tests:
|
||||
```
|
||||
pytest
|
||||
```
|
||||
|
||||
3. Format code:
|
||||
```
|
||||
black .
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
48
README_SOLUTION.md
Normal file
48
README_SOLUTION.md
Normal file
|
@ -0,0 +1,48 @@
|
|||
# Podcastrr - Solution to 500 Internal Server Error
|
||||
|
||||
## Problem
|
||||
|
||||
The application was returning a 500 Internal Server Error when accessing the root URL. This was likely due to the database not being properly initialized with the required tables.
|
||||
|
||||
## Solution
|
||||
|
||||
1. Created all necessary templates and CSS files for the application
|
||||
2. Created a database initialization script (`init_db.py`) to create the required tables and default settings
|
||||
3. Ran the initialization script to set up the database
|
||||
|
||||
## How to Run the Application
|
||||
|
||||
1. Make sure you have all the dependencies installed:
|
||||
```
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
2. Initialize the database (if you haven't already):
|
||||
```
|
||||
python init_db.py
|
||||
```
|
||||
|
||||
3. Run the application:
|
||||
```
|
||||
python main.py
|
||||
```
|
||||
|
||||
4. Open your browser and navigate to `http://localhost:5000`
|
||||
|
||||
## What to Expect
|
||||
|
||||
You should now see the home page of the Podcastrr application instead of a 500 Internal Server Error. The application allows you to:
|
||||
|
||||
- Search for podcasts
|
||||
- Track podcasts
|
||||
- Download podcast episodes
|
||||
- Configure download settings
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you still encounter issues:
|
||||
|
||||
1. Check that the database file (`instance/podcastrr.db`) exists and is not empty
|
||||
2. Verify that all required templates are in the `templates` directory
|
||||
3. Ensure that the CSS file is in the `static/css` directory
|
||||
4. Check the application logs for specific error messages
|
3
app/models/__init__.py
Normal file
3
app/models/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
Models package for Podcastrr.
|
||||
"""
|
26
app/models/database.py
Normal file
26
app/models/database.py
Normal 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
89
app/models/podcast.py
Normal 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
33
app/models/settings.py
Normal 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
3
app/services/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
Services package for Podcastrr.
|
||||
"""
|
179
app/services/podcast_downloader.py
Normal file
179
app/services/podcast_downloader.py
Normal 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
|
317
app/services/podcast_search.py
Normal file
317
app/services/podcast_search.py
Normal 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
|
184
app/services/podcast_updater.py
Normal file
184
app/services/podcast_updater.py
Normal 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
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)}
|
25
init_db.py
Normal file
25
init_db.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
from app.web.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()
|
||||
|
||||
with app.app_context():
|
||||
# Create all tables
|
||||
db.create_all()
|
||||
|
||||
# Check if settings exist, create default if not
|
||||
if not Settings.query.first():
|
||||
default_settings = Settings(
|
||||
download_path=app.config['DOWNLOAD_PATH'],
|
||||
naming_format="{podcast_title}/{episode_title}",
|
||||
auto_download=False,
|
||||
max_downloads=5,
|
||||
delete_after_days=30
|
||||
)
|
||||
db.session.add(default_settings)
|
||||
db.session.commit()
|
||||
print("Created default settings")
|
||||
|
||||
print("Database initialized successfully!")
|
BIN
instance/podcastrr.db
Normal file
BIN
instance/podcastrr.db
Normal file
Binary file not shown.
24
main.py
Normal file
24
main.py
Normal file
|
@ -0,0 +1,24 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Podcastrr - A podcast management application similar to Sonarr but for podcasts.
|
||||
"""
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from app.web.app import create_app
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
# Create Flask application instance
|
||||
app = create_app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Get port from environment variable or use default
|
||||
port = int(os.environ.get("PORT", 5000))
|
||||
|
||||
# Run the application
|
||||
app.run(
|
||||
host="0.0.0.0",
|
||||
port=port,
|
||||
debug=os.environ.get("FLASK_ENV") == "development"
|
||||
)
|
26
requirements.txt
Normal file
26
requirements.txt
Normal file
|
@ -0,0 +1,26 @@
|
|||
# Core dependencies
|
||||
Flask==3.1.1
|
||||
SQLAlchemy==2.0.27
|
||||
alembic==1.7.3
|
||||
requests==2.32.4
|
||||
beautifulsoup4==4.10.0
|
||||
feedparser==6.0.8
|
||||
python-dotenv==0.19.0
|
||||
|
||||
# Web interface
|
||||
Flask-SQLAlchemy==3.1.0
|
||||
Flask-WTF==0.15.1
|
||||
Flask-Login==0.5.0
|
||||
Flask-Migrate==3.1.0
|
||||
|
||||
# API
|
||||
Flask-RESTful==0.3.9
|
||||
marshmallow==3.13.0
|
||||
|
||||
# Testing
|
||||
pytest==6.2.5
|
||||
pytest-cov==2.12.1
|
||||
|
||||
# Development
|
||||
black==24.3.0
|
||||
flake8==3.9.2
|
569
static/css/style.css
Normal file
569
static/css/style.css
Normal file
|
@ -0,0 +1,569 @@
|
|||
/* Podcastrr Styles */
|
||||
|
||||
/* Reset and Base Styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #2c7be5;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.layout-container {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-areas:
|
||||
"header header"
|
||||
"sidebar main";
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.main-header {
|
||||
grid-area: header;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo h1 {
|
||||
font-size: 1.5rem;
|
||||
color: #2c7be5;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
flex: 1;
|
||||
max-width: 500px;
|
||||
margin-left: 2rem;
|
||||
}
|
||||
|
||||
.header-search-form {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.header-search-form input {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
|
||||
.header-search-form .btn-search {
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
grid-area: sidebar;
|
||||
background-color: #fff;
|
||||
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.05);
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.sidebar-nav li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar-nav a {
|
||||
display: block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
color: #555;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-nav a:hover {
|
||||
background-color: #f5f7fa;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sidebar-nav a.active {
|
||||
color: #2c7be5;
|
||||
background-color: #edf2fa;
|
||||
border-left: 3px solid #2c7be5;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main-content {
|
||||
grid-area: main;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
section {
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 1.5rem 0;
|
||||
color: #777;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Components */
|
||||
.btn {
|
||||
display: inline-block;
|
||||
background-color: #2c7be5;
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background-color: #1a68d1;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #e63757;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #d52b4a;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.flash-messages {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.flash-message {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.flash-message.success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.flash-message.error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.flash-message.info {
|
||||
background-color: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
/* Podcast Grid */
|
||||
.podcast-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.podcast-card {
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.podcast-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.podcast-card img {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.podcast-card .no-image {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
background-color: #e9ecef;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.podcast-card h3, .podcast-card h4 {
|
||||
padding: 0.75rem 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.podcast-card .author {
|
||||
padding: 0 1rem 0.75rem;
|
||||
color: #6c757d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.podcast-card .genre {
|
||||
padding: 0 1rem 0.75rem;
|
||||
color: #6c757d;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.podcast-actions {
|
||||
padding: 0.75rem 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
/* Podcast Detail Page */
|
||||
.podcast-detail {
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.podcast-header {
|
||||
display: flex;
|
||||
padding: 2rem;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.podcast-image {
|
||||
flex: 0 0 200px;
|
||||
margin-right: 2rem;
|
||||
}
|
||||
|
||||
.podcast-image img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.podcast-image .no-image {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background-color: #e9ecef;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #6c757d;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.podcast-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.podcast-info h1 {
|
||||
margin-bottom: 0.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.podcast-author {
|
||||
color: #6c757d;
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.podcast-description {
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.6;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.podcast-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.podcast-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem;
|
||||
color: #6c757d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Episodes Section */
|
||||
.episodes-section {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.episodes-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.episodes-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.episode-item {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.episode-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.episode-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.episode-title {
|
||||
font-size: 1.2rem;
|
||||
margin: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.episode-date {
|
||||
color: #6c757d;
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.episode-description {
|
||||
margin-bottom: 1rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.episode-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.episode-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
color: #6c757d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.episode-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.no-episodes {
|
||||
background-color: #f8f9fa;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
border-radius: 8px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge.downloaded {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="number"] {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
color: #6c757d;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.form-group.checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form-group.checkbox input {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.search-form .form-group {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.search-form input {
|
||||
flex: 1;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
/* Dashboard */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
font-size: 1rem;
|
||||
color: #6c757d;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-card .stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: #2c7be5;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Settings */
|
||||
.preview {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 992px) {
|
||||
.layout-container {
|
||||
grid-template-columns: 180px 1fr;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
max-width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.layout-container {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
"header"
|
||||
"main";
|
||||
}
|
||||
|
||||
.main-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
max-width: 100%;
|
||||
margin: 1rem 0 0 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.podcast-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.podcast-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
25
templates/about.html
Normal file
25
templates/about.html
Normal file
|
@ -0,0 +1,25 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}About{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="about">
|
||||
<h2>About Podcastrr</h2>
|
||||
<p>Podcastrr is a podcast management application similar to Sonarr but for podcasts, built with Python and Flask.</p>
|
||||
|
||||
<h3>Features</h3>
|
||||
<ul>
|
||||
<li><strong>Search for Podcasts</strong>: Find podcasts from various sources</li>
|
||||
<li><strong>Track Podcasts</strong>: Monitor your favorite podcasts for new episodes</li>
|
||||
<li><strong>Download Management</strong>: Automatically download new episodes and manage storage</li>
|
||||
<li><strong>Custom Naming</strong>: Configure how downloaded files are named</li>
|
||||
<li><strong>Web Interface</strong>: Manage everything through an intuitive web interface</li>
|
||||
</ul>
|
||||
|
||||
<h3>Version</h3>
|
||||
<p>1.0.0</p>
|
||||
|
||||
<h3>License</h3>
|
||||
<p>MIT</p>
|
||||
</section>
|
||||
{% endblock %}
|
60
templates/base.html
Normal file
60
templates/base.html
Normal file
|
@ -0,0 +1,60 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}{{ title }}{% endblock %} - Podcastrr</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout-container">
|
||||
<!-- Header with app name and search -->
|
||||
<header class="main-header">
|
||||
<div class="logo">
|
||||
<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>
|
||||
</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">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flash-messages">
|
||||
{% for category, message in messages %}
|
||||
<div class="flash-message {{ category }}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>© 2023 Podcastrr</p>
|
||||
</footer>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
53
templates/dashboard.html
Normal file
53
templates/dashboard.html
Normal file
|
@ -0,0 +1,53 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="dashboard">
|
||||
<h2>Dashboard</h2>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<h3>Total Podcasts</h3>
|
||||
<p class="stat-value">{{ total_podcasts }}</p>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<h3>Recent Episodes</h3>
|
||||
<p class="stat-value">Coming Soon</p>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<h3>Downloads</h3>
|
||||
<p class="stat-value">Coming Soon</p>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<h3>Storage Used</h3>
|
||||
<p class="stat-value">Coming Soon</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>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Simple JavaScript for the buttons (to be implemented)
|
||||
document.getElementById('update-all').addEventListener('click', function() {
|
||||
alert('Update all podcasts functionality coming soon!');
|
||||
});
|
||||
|
||||
document.getElementById('clean-downloads').addEventListener('click', function() {
|
||||
alert('Clean old downloads functionality coming soon!');
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
32
templates/index.html
Normal file
32
templates/index.html
Normal file
|
@ -0,0 +1,32 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% 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>
|
||||
|
||||
<section class="recent-podcasts">
|
||||
<h3>Recent Podcasts</h3>
|
||||
{% 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>
|
||||
{% else %}
|
||||
<p>No podcasts found. <a href="{{ url_for('podcasts.search') }}">Search for podcasts</a> to get started.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
39
templates/podcasts/index.html
Normal file
39
templates/podcasts/index.html
Normal file
|
@ -0,0 +1,39 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Your 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>
|
||||
</div>
|
||||
|
||||
{% 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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>You haven't added any podcasts yet.</p>
|
||||
<a href="{{ url_for('podcasts.search') }}" class="btn">Search for Podcasts</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
43
templates/podcasts/search.html
Normal file
43
templates/podcasts/search.html
Normal file
|
@ -0,0 +1,43 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Search for Podcasts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="search">
|
||||
<h2>Search for Podcasts</h2>
|
||||
|
||||
<form method="post" class="search-form">
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% 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 }}">
|
||||
{% else %}
|
||||
<div class="no-image">No Image</div>
|
||||
{% 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>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% elif request.method == 'POST' %}
|
||||
<div class="no-results">
|
||||
<p>No podcasts found matching your search. Try different keywords.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
86
templates/podcasts/view.html
Normal file
86
templates/podcasts/view.html
Normal file
|
@ -0,0 +1,86 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ podcast.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="podcast-detail">
|
||||
<div class="podcast-header">
|
||||
<div class="podcast-image">
|
||||
{% if podcast.image_url %}
|
||||
<img src="{{ podcast.image_url }}" alt="{{ podcast.title }}">
|
||||
{% else %}
|
||||
<div class="no-image">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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="episodes-section">
|
||||
<div class="episodes-header">
|
||||
<h2>Episodes ({{ episodes|length }})</h2>
|
||||
</div>
|
||||
|
||||
{% if episodes %}
|
||||
<div class="episodes-list">
|
||||
{% 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 %}
|
||||
|
||||
{% 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>
|
||||
{% else %}
|
||||
<a href="{{ url_for('podcasts.download', episode_id=episode.id) }}" class="btn btn-sm">Download</a>
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ episode.audio_url }}" target="_blank" class="btn btn-sm btn-secondary">Stream</a>
|
||||
</div>
|
||||
</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>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
73
templates/settings/index.html
Normal file
73
templates/settings/index.html
Normal file
|
@ -0,0 +1,73 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Settings{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="settings">
|
||||
<h2>Application Settings</h2>
|
||||
|
||||
<form method="post" class="settings-form">
|
||||
<div class="form-group">
|
||||
<label for="download_path">Download Path</label>
|
||||
<input type="text" name="download_path" id="download_path" value="{{ settings.download_path }}" required>
|
||||
<small>Directory where podcast episodes will be downloaded</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="naming_format">Naming Format</label>
|
||||
<input type="text" name="naming_format" id="naming_format" value="{{ settings.naming_format }}" required>
|
||||
<small>Format for downloaded files. Available variables: {podcast_title}, {episode_title}, {published_date}, {episode_number}, {author}</small>
|
||||
<div id="naming-preview" class="preview">
|
||||
<strong>Preview:</strong> <span id="preview-text">{{ settings.naming_format }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group checkbox">
|
||||
<input type="checkbox" name="auto_download" id="auto_download" {% if settings.auto_download %}checked{% endif %}>
|
||||
<label for="auto_download">Auto-download new episodes</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="max_downloads">Maximum Concurrent Downloads</label>
|
||||
<input type="number" name="max_downloads" id="max_downloads" value="{{ settings.max_downloads }}" min="1" max="10" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="delete_after_days">Delete Episodes After (days)</label>
|
||||
<input type="number" name="delete_after_days" id="delete_after_days" value="{{ settings.delete_after_days }}" min="0" required>
|
||||
<small>Set to 0 to never delete</small>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn">Save Settings</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Preview naming format
|
||||
const namingFormatInput = document.getElementById('naming_format');
|
||||
const previewText = document.getElementById('preview-text');
|
||||
|
||||
namingFormatInput.addEventListener('input', function() {
|
||||
// Send AJAX request to get preview
|
||||
fetch('{{ url_for("settings.naming_preview") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: 'naming_format=' + encodeURIComponent(this.value)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.preview) {
|
||||
previewText.textContent = data.preview;
|
||||
} else if (data.error) {
|
||||
previewText.textContent = 'Error: ' + data.error;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
Loading…
Add table
Add a link
Reference in a new issue