initial v1
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.idea
|
||||||
|
.git
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
|
||||||
|
name: Docker Publish
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main" ]
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push-image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Read version from file
|
||||||
|
id: version
|
||||||
|
run: echo "TAG=$(cat VERSION)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build, Test and Push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
target: final
|
||||||
|
push: true
|
||||||
|
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.TAG }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
*.iml
|
||||||
|
.idea
|
||||||
|
.venv
|
||||||
|
local
|
||||||
|
.DS_Store
|
||||||
|
**/__pycache__
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
|
||||||
|
# Base stage for installing dependencies
|
||||||
|
FROM python:3.10-slim AS base
|
||||||
|
WORKDIR /app
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# Builder stage for production dependencies
|
||||||
|
FROM base AS builder
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Default config and data paths
|
||||||
|
ENV FEEDS_FILE_PATH=/var/mbz-rss-feeder/feeds.yml
|
||||||
|
ENV CONFIG_FILE_PATH=/etc/mbz-rss-feeder.yml
|
||||||
|
ENV CACHE_DIR=/var/mbz-rss-feeder/cache
|
||||||
|
ENV LOG_FILE=/var/log/mbz-rss-feeder.log
|
||||||
|
ENV LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
RUN mkdir -p /var/mbz-rss-feeder/cache \
|
||||||
|
&& touch /var/log/mbz-rss-feeder.log \
|
||||||
|
&& touch /var/mbz-rss-feeder/feeds.yml \
|
||||||
|
&& touch /etc/mbz-rss-feeder.yml
|
||||||
|
|
||||||
|
# Test stage
|
||||||
|
FROM builder AS test
|
||||||
|
COPY mzb_rss_service/tests/requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY . .
|
||||||
|
CMD ["python -m pytest"]
|
||||||
|
|
||||||
|
# Final production stage
|
||||||
|
FROM base AS final
|
||||||
|
COPY --from=builder /usr/local/lib/python3.10/site-packages /usr/local/lib/python3.10/site-packages
|
||||||
|
COPY --from=builder /usr/local/bin /usr/local/bin
|
||||||
|
COPY mzb_rss_service mzb_rss_service
|
||||||
|
COPY VERSION .
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "mzb_rss_service.main:app"]
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# MusicBrainz RSS Feeder
|
||||||
|
`mbz-rss-feeder`
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
A small webserver that provides OPML, RSS and a minimal management UI to generate and manage feeds for artist release on
|
||||||
|
[MusicBrainz](musicbrainz.org).
|
||||||
|
|
||||||
|
The OPML can be used to discover the current feeds.
|
||||||
|
A feed lists all new releases for a group of artists.
|
||||||
|
|
||||||
|
## Web UI
|
||||||
|
### Feeds and artists
|
||||||
|
You can manage feeds and artists in the web UI:
|
||||||
|
- create a new feed or delete a feed
|
||||||
|
- edit a feed: give it a name and manage the artists in the feed
|
||||||
|
- add an artist
|
||||||
|
- remove an artist
|
||||||
|
|
||||||
|
**Artist Search*
|
||||||
|
When adding an artist, the service will query the musicbrainz API for the given string and return a list of matching artists.
|
||||||
|
The user can then click on the artist to add to the feed.
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
In the settings page, you can
|
||||||
|
* set the number of days to look back (default: 90 days)
|
||||||
|
* the caching time (default: 8 hours)
|
||||||
|
|
||||||
|
## Technical Features
|
||||||
|
### Persistence
|
||||||
|
These files should be mounted to persist the configuration.
|
||||||
|
* the feed and artist information is stored in `/var/mbz-rss-feeder/feeds.yml`
|
||||||
|
* the service configuration is stored in `/etc/mbz-rss-feeder.yml`
|
||||||
|
|
||||||
|
Cached data is stored under `/var/mbz-rss-feeder/cache` but is not required to persist.
|
||||||
|
|
||||||
|
Updates to the configuration will also create a timestamped backup of the previous configuration.
|
||||||
|
|
||||||
|
### Persistence updates
|
||||||
|
|
||||||
|
### deployment
|
||||||
|
#### docker
|
||||||
|
`docker run \
|
||||||
|
-p 8080:8080 -e LOG_LEVEL=debug --rm --name mbz-rss\
|
||||||
|
-v ./local/testdata/feeds.yml:/var/mbz-rss-feeder/feeds.yml \
|
||||||
|
-v ./local/testdata/mbz-rss-feeder.yml:/etc/mbz-rss-feeder.yml \
|
||||||
|
-v ./local/testdata/cache:/var/mbz-rss-feeder/cache \
|
||||||
|
-v ./local/testdata/mbz-rss-feeder.log:/var/mbz-rss-feeder.log \
|
||||||
|
--env-file ./local/testdata/.env \
|
||||||
|
docker.io/library/mzb-rss-feeder:1.0.0`
|
||||||
|
|
||||||
|
After startup the server can be accessed through http://docker-host:8080.
|
||||||
|
|
||||||
|
**Environment**
|
||||||
|
```bash
|
||||||
|
# User Agent for MusicBrainz API
|
||||||
|
MB_APP_NAME=MinifluxRSSGenerator
|
||||||
|
MB_VERSION=2.0
|
||||||
|
MB_CONTACT=yourname@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dependencies/Requirements
|
||||||
|
The pypi package musicbrainzngs is used to communicate with MusicBrainz.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# mzb_rss_service package
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
|
||||||
|
import shutil
|
||||||
|
import os
|
||||||
|
import yaml
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
FEEDS_FILE_PATH = os.path.expandvars(os.environ.get('FEEDS_FILE_PATH', '/var/mbz-rss-feeder/feeds.yml'))
|
||||||
|
CONFIG_FILE_PATH = os.path.expandvars(os.environ.get('CONFIG_FILE_PATH', '/etc/mbz-rss-feeder.yml'))
|
||||||
|
CACHE_DIR = os.path.expandvars(os.environ.get('CACHE_DIR', '/var/mbz-rss-feeder/cache'))
|
||||||
|
MB_APP_NAME = os.path.expandvars(os.environ.get('MB_APP_NAME', 'mzb-rss-service'))
|
||||||
|
MB_VERSION = os.path.expandvars(os.environ.get('MB_VERSION', '0.1.0'))
|
||||||
|
MB_CONTACT = os.path.expandvars(os.environ.get('MB_CONTACT', 'someone@somewhere.com'))
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
def __init__(self):
|
||||||
|
self._feeds_data = self._load_yaml(FEEDS_FILE_PATH)
|
||||||
|
self._settings = self._load_yaml(CONFIG_FILE_PATH)
|
||||||
|
self.FEEDS_FILE_PATH = FEEDS_FILE_PATH
|
||||||
|
self.CONFIG_FILE_PATH = CONFIG_FILE_PATH
|
||||||
|
self.CACHE_DIR = CACHE_DIR
|
||||||
|
self.MB_APP_NAME = MB_APP_NAME
|
||||||
|
self.MB_VERSION = MB_VERSION
|
||||||
|
self.MB_CONTACT = MB_CONTACT
|
||||||
|
try:
|
||||||
|
with open(os.path.join(os.path.dirname(__file__), '..', 'VERSION')) as f:
|
||||||
|
self.VERSION = f.read().strip()
|
||||||
|
except FileNotFoundError:
|
||||||
|
self.VERSION = '0.0.0'
|
||||||
|
|
||||||
|
def __getattr__(self, name):
|
||||||
|
if name == 'feeds':
|
||||||
|
return self._feeds_data.get('feeds', [])
|
||||||
|
if name in self._settings['service']:
|
||||||
|
return self._settings['service'][name]
|
||||||
|
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
|
||||||
|
|
||||||
|
def get_settings(self):
|
||||||
|
return self._settings
|
||||||
|
|
||||||
|
def _load_yaml(self, file_path):
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
return {}
|
||||||
|
with open(file_path, 'r') as f:
|
||||||
|
return yaml.safe_load(f) or {}
|
||||||
|
|
||||||
|
def _save_yaml(self, data, file_path):
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
backup_path = f"{file_path}.{datetime.now().strftime('%Y%m%d%H%M%S')}.bak"
|
||||||
|
shutil.copy(file_path, backup_path)
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||||
|
with open(file_path, 'w') as f:
|
||||||
|
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def feeds(self):
|
||||||
|
return self._feeds_data.get('feeds', [])
|
||||||
|
|
||||||
|
def save_feeds(self):
|
||||||
|
self._save_yaml(self._feeds_data, FEEDS_FILE_PATH)
|
||||||
|
|
||||||
|
def get_feed(self, feed_id):
|
||||||
|
for feed in self.feeds:
|
||||||
|
if feed['id'] == feed_id:
|
||||||
|
return feed
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add_feed(self, name):
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
new_feed = {
|
||||||
|
'id': str(uuid.uuid4()),
|
||||||
|
'name': name,
|
||||||
|
'artists': [],
|
||||||
|
'created_at': now,
|
||||||
|
'updated_at': now
|
||||||
|
}
|
||||||
|
if 'feeds' not in self._feeds_data:
|
||||||
|
self._feeds_data['feeds'] = []
|
||||||
|
self._feeds_data['feeds'].append(new_feed)
|
||||||
|
self.save_feeds()
|
||||||
|
return new_feed
|
||||||
|
|
||||||
|
def delete_feed(self, feed_id):
|
||||||
|
if 'feeds' in self._feeds_data:
|
||||||
|
self._feeds_data['feeds'] = [feed for feed in self.feeds if feed['id'] != feed_id]
|
||||||
|
self.save_feeds()
|
||||||
|
|
||||||
|
def add_artist_to_feed(self, feed_id, artist_id, artist_name):
|
||||||
|
for feed in self.feeds:
|
||||||
|
if feed['id'] == feed_id:
|
||||||
|
if 'artists' not in feed:
|
||||||
|
feed['artists'] = []
|
||||||
|
if not any(a['id'] == artist_id for a in feed['artists']):
|
||||||
|
feed['artists'].append({'id': artist_id, 'name': artist_name})
|
||||||
|
feed['updated_at'] = datetime.now(timezone.utc).isoformat()
|
||||||
|
self.save_feeds()
|
||||||
|
break
|
||||||
|
|
||||||
|
def remove_artist_from_feed(self, feed_id, artist_id):
|
||||||
|
for feed in self.feeds:
|
||||||
|
if feed['id'] == feed_id and 'artists' in feed:
|
||||||
|
original_artist_count = len(feed['artists'])
|
||||||
|
feed['artists'] = [a for a in feed['artists'] if a['id'] != artist_id]
|
||||||
|
if len(feed['artists']) < original_artist_count:
|
||||||
|
feed['updated_at'] = datetime.now(timezone.utc).isoformat()
|
||||||
|
self.save_feeds()
|
||||||
|
break
|
||||||
|
|
||||||
|
def get_artist_name(self, artist_id):
|
||||||
|
for feed in self.feeds:
|
||||||
|
for artist in feed.get('artists', []):
|
||||||
|
if artist['id'] == artist_id:
|
||||||
|
return artist['name']
|
||||||
|
return 'unknown artist'
|
||||||
|
|
||||||
|
def save_settings(self, days_back, cache_time_hours):
|
||||||
|
logger.debug(f"Saving settings: days_back={days_back}, cache_time_hours={cache_time_hours}")
|
||||||
|
if 'service' not in self._settings:
|
||||||
|
self._settings['service'] = {}
|
||||||
|
if days_back is not None:
|
||||||
|
self._settings['service']['days_back'] = int(days_back)
|
||||||
|
if cache_time_hours is not None:
|
||||||
|
self._settings['service']['cache_time_hours'] = int(cache_time_hours)
|
||||||
|
self._save_yaml(self._settings, CONFIG_FILE_PATH)
|
||||||
|
|
||||||
|
# Global instance
|
||||||
|
config = Config()
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
|
||||||
|
from .config import config
|
||||||
|
import logging
|
||||||
|
from flask import Flask, jsonify, render_template, request, redirect, url_for
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from email.utils import formatdate, parsedate_to_datetime
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from . import musicbrainz
|
||||||
|
|
||||||
|
log_level_str = os.environ.get('LOG_LEVEL', 'INFO').upper()
|
||||||
|
log_level = getattr(logging, log_level_str, logging.INFO)
|
||||||
|
log_file = os.path.expandvars(os.environ.get('LOG_FILE', '/var/log/mbz-rss-feeder.log'))
|
||||||
|
log_format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
mbz_log_level_str = os.environ.get('LOG_LEVEL', 'WARNING').upper()
|
||||||
|
mbz_log_level = os.environ.get('MBZ_LOG_LEVEL', logging.WARNING)
|
||||||
|
|
||||||
|
# Configure root logger
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
root_logger.setLevel(log_level)
|
||||||
|
formatter = logging.Formatter(log_format)
|
||||||
|
|
||||||
|
# configure musicbrainz logger
|
||||||
|
logging.getLogger('musicbrainzngs').setLevel(mbz_log_level)
|
||||||
|
|
||||||
|
# Clear existing handlers
|
||||||
|
if root_logger.hasHandlers():
|
||||||
|
root_logger.handlers.clear()
|
||||||
|
|
||||||
|
# Add file handler
|
||||||
|
file_handler = logging.FileHandler(log_file)
|
||||||
|
file_handler.setFormatter(formatter)
|
||||||
|
root_logger.addHandler(file_handler)
|
||||||
|
|
||||||
|
# Add stdout handler if running with gunicorn (in container)
|
||||||
|
if 'gunicorn' in sys.argv[0]:
|
||||||
|
stdout_handler = logging.StreamHandler(sys.stdout)
|
||||||
|
stdout_handler.setFormatter(formatter)
|
||||||
|
root_logger.addHandler(stdout_handler)
|
||||||
|
|
||||||
|
|
||||||
|
# Get the werkzeug logger and remove its default handlers
|
||||||
|
werkzeug_logger = logging.getLogger('werkzeug')
|
||||||
|
for handler in werkzeug_logger.handlers:
|
||||||
|
werkzeug_logger.removeHandler(handler)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
logger.info("Starting mzb-rss-service with the following configuration:")
|
||||||
|
logger.info(f" - Log Level: {log_level_str}")
|
||||||
|
logger.info(f" - Log File: {log_file}")
|
||||||
|
logger.info(f" - Feeds File: {config.FEEDS_FILE_PATH}")
|
||||||
|
logger.info(f" - Config File: {config.CONFIG_FILE_PATH}")
|
||||||
|
logger.info(f" - MusicBrainz App Name: {config.MB_APP_NAME}")
|
||||||
|
logger.info(f" - MusicBrainz App Version: {config.MB_VERSION}")
|
||||||
|
logger.info(f" - MusicBrainz Contact: {config.MB_CONTACT}")
|
||||||
|
logger.info(f" - MusicBrainz Log Level: {mbz_log_level_str}")
|
||||||
|
|
||||||
|
|
||||||
|
# init
|
||||||
|
musicbrainz.init_musicbrainz()
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.template_folder = 'templates'
|
||||||
|
|
||||||
|
|
||||||
|
def _get_cache_file_path(feed_id):
|
||||||
|
"""Constructs the full path for a feed's cache file."""
|
||||||
|
return os.path.join(config.CACHE_DIR, f"{feed_id}.xml")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_cache_last_build_date(cache_file):
|
||||||
|
"""Parses the cache file and returns its last build date as a timezone-aware datetime object."""
|
||||||
|
if not os.path.exists(cache_file):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
tree = ET.parse(cache_file)
|
||||||
|
root = tree.getroot()
|
||||||
|
last_build_date_str = root.findtext('channel/lastBuildDate')
|
||||||
|
if not last_build_date_str:
|
||||||
|
return None
|
||||||
|
|
||||||
|
last_build_date = parsedate_to_datetime(last_build_date_str)
|
||||||
|
# Ensure last_build_date is timezone-aware (in UTC)
|
||||||
|
if last_build_date.tzinfo is None:
|
||||||
|
return last_build_date.replace(tzinfo=timezone.utc)
|
||||||
|
return last_build_date
|
||||||
|
except (ET.ParseError, FileNotFoundError) as e:
|
||||||
|
logger.warning(f"Could not read or parse cache file {cache_file}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_cache_stale(last_build_date, feed_data, cache_time_hours):
|
||||||
|
"""Determines if the cache is stale based on update times and age."""
|
||||||
|
if not last_build_date:
|
||||||
|
return True # No build date means it's stale
|
||||||
|
|
||||||
|
# Invalidate if feed config was updated since last cache build
|
||||||
|
if 'updated_at' in feed_data and feed_data['updated_at']:
|
||||||
|
feed_updated_at = datetime.fromisoformat(feed_data['updated_at'])
|
||||||
|
if feed_updated_at > last_build_date:
|
||||||
|
logger.debug(f"Feed {feed_data['id']} has been updated. Invalidating cache.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Invalidate if cache is older than the configured time
|
||||||
|
if datetime.now(timezone.utc) - last_build_date > timedelta(hours=cache_time_hours):
|
||||||
|
logger.debug(f"Cache for feed {feed_data['id']} is older than {cache_time_hours} hours. Invalidating.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _check_cache(feed_id):
|
||||||
|
"""Checks for a valid cached feed and returns it if found."""
|
||||||
|
feed_data = config.get_feed(feed_id)
|
||||||
|
if not feed_data:
|
||||||
|
return None # Feed doesn't exist, so no cache.
|
||||||
|
|
||||||
|
cache_file = _get_cache_file_path(feed_id)
|
||||||
|
last_build_date = _get_cache_last_build_date(cache_file)
|
||||||
|
|
||||||
|
cache_time_hours = int(config.get_settings().get('service', {}).get('cache_time_hours', 24))
|
||||||
|
|
||||||
|
if not _is_cache_stale(last_build_date, feed_data, cache_time_hours):
|
||||||
|
try:
|
||||||
|
with open(cache_file, 'r') as f:
|
||||||
|
logger.debug(f"Serving cached feed for {feed_id}")
|
||||||
|
return f.read(), {"Content-Type": "application/xml"}
|
||||||
|
except IOError as e:
|
||||||
|
logger.warning(f"Could not read cache file {cache_file}: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
logger.debug("Request for index page")
|
||||||
|
feeds = config.feeds
|
||||||
|
return render_template('index.html', feeds=feeds)
|
||||||
|
|
||||||
|
@app.route("/feed/create", methods=["POST"])
|
||||||
|
def create_feed():
|
||||||
|
feed_name = request.form.get('name')
|
||||||
|
logger.debug(f"Request to create feed with name: {feed_name}")
|
||||||
|
if feed_name and feed_name.strip():
|
||||||
|
config.add_feed(feed_name.strip())
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
|
@app.route("/feed/<feed_id>/delete", methods=["POST"])
|
||||||
|
def delete_feed(feed_id):
|
||||||
|
logger.debug(f"Request to delete feed with id: {feed_id}")
|
||||||
|
config.delete_feed(feed_id)
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
|
@app.route("/feed/<feed_id>/edit")
|
||||||
|
def edit_feed(feed_id):
|
||||||
|
logger.debug(f"Request to edit feed with id: {feed_id}")
|
||||||
|
feed = config.get_feed(feed_id)
|
||||||
|
if not feed:
|
||||||
|
return "Feed not found", 404
|
||||||
|
return render_template('feed.html', feed=feed)
|
||||||
|
|
||||||
|
@app.route("/feed/<feed_id>/artist/add", methods=["POST"])
|
||||||
|
def add_artist(feed_id):
|
||||||
|
artist_id = request.form.get('artist_id')
|
||||||
|
artist_name = request.form.get('artist_name')
|
||||||
|
logger.debug(f"Request to add artist '{artist_name}' ({artist_id}) to feed {feed_id}")
|
||||||
|
if artist_id and artist_name:
|
||||||
|
config.add_artist_to_feed(feed_id, artist_id, artist_name)
|
||||||
|
return redirect(url_for('edit_feed', feed_id=feed_id))
|
||||||
|
|
||||||
|
@app.route("/feed/<feed_id>/artist/<artist_id>/remove", methods=["POST"])
|
||||||
|
def remove_artist(feed_id, artist_id):
|
||||||
|
logger.debug(f"Request to remove artist {artist_id} from feed {feed_id}")
|
||||||
|
config.remove_artist_from_feed(feed_id, artist_id)
|
||||||
|
return redirect(url_for('edit_feed', feed_id=feed_id))
|
||||||
|
|
||||||
|
@app.route("/artist/search")
|
||||||
|
def search_artist():
|
||||||
|
query = request.args.get('q', '')
|
||||||
|
logger.debug(f"Request to search for artist with query: '{query}'")
|
||||||
|
artists = musicbrainz.search_artists(query)
|
||||||
|
return jsonify(artists)
|
||||||
|
|
||||||
|
@app.route("/settings", methods=["GET", "POST"])
|
||||||
|
def settings():
|
||||||
|
if request.method == "POST":
|
||||||
|
logger.debug("Request to update settings")
|
||||||
|
days_back = request.form.get('days_back')
|
||||||
|
cache_time_hours = request.form.get('cache_time_hours')
|
||||||
|
config.save_settings(days_back, cache_time_hours)
|
||||||
|
return redirect(url_for('settings'))
|
||||||
|
|
||||||
|
logger.debug("Request for settings page")
|
||||||
|
return render_template('settings.html', settings=config)
|
||||||
|
|
||||||
|
@app.route("/opml")
|
||||||
|
def opml():
|
||||||
|
logger.debug("Request for OPML file")
|
||||||
|
feeds = config.feeds
|
||||||
|
opml_content = render_template('opml.xml', feeds=feeds)
|
||||||
|
return opml_content, {"Content-Type": "application/xml"}
|
||||||
|
|
||||||
|
@app.route('/feed/<feed_id>')
|
||||||
|
def get_feed_rss(feed_id):
|
||||||
|
logger.debug(f"Request for RSS feed with id: {feed_id}")
|
||||||
|
|
||||||
|
# Check cache for a valid feed before generating it
|
||||||
|
cached_response = _check_cache(feed_id)
|
||||||
|
if cached_response:
|
||||||
|
return cached_response
|
||||||
|
|
||||||
|
# cache outdated or not found, generate a new feed
|
||||||
|
logger.debug(f"Generating new feed for {feed_id}")
|
||||||
|
feed_data = config.get_feed(feed_id)
|
||||||
|
if not feed_data:
|
||||||
|
return "Feed not found", 404
|
||||||
|
|
||||||
|
# Add rfc822 formatted updated_at for template
|
||||||
|
if 'updated_at' in feed_data and feed_data['updated_at']:
|
||||||
|
updated_at_dt = datetime.fromisoformat(feed_data['updated_at'])
|
||||||
|
feed_data['updated_at_rfc822'] = formatdate(updated_at_dt.timestamp())
|
||||||
|
else:
|
||||||
|
# Fallback for older feeds without updated_at
|
||||||
|
now_utc = datetime.now(timezone.utc)
|
||||||
|
feed_data['updated_at_rfc822'] = formatdate(now_utc.timestamp())
|
||||||
|
|
||||||
|
all_releases = []
|
||||||
|
if 'artists' in feed_data:
|
||||||
|
for artist in feed_data['artists']:
|
||||||
|
releases = musicbrainz.get_artist_releases(artist['id'])
|
||||||
|
all_releases.extend(releases)
|
||||||
|
|
||||||
|
# Sort releases by date, newest first
|
||||||
|
all_releases.sort(key=lambda r: r.get('date', '0000-00-00'), reverse=True)
|
||||||
|
|
||||||
|
last_build_date = formatdate(datetime.now(timezone.utc).timestamp())
|
||||||
|
|
||||||
|
rss_content = render_template('feed.xml', feed=feed_data, releases=all_releases, last_build_date=last_build_date)
|
||||||
|
|
||||||
|
cache_dir = config.CACHE_DIR
|
||||||
|
os.makedirs(cache_dir, exist_ok=True)
|
||||||
|
cache_file = os.path.join(cache_dir, f"{feed_id}.xml")
|
||||||
|
try:
|
||||||
|
with open(cache_file, 'w') as f:
|
||||||
|
f.write(rss_content)
|
||||||
|
logger.debug(f"Cached feed '{feed_data['name']}' at {cache_file}")
|
||||||
|
except IOError as e:
|
||||||
|
logger.warning(f"Could not write feed '{feed_data['name']}' to cache file {cache_file}: {e}")
|
||||||
|
|
||||||
|
return rss_content, {"Content-Type": "application/xml"}
|
||||||
|
|
||||||
|
@app.route("/health")
|
||||||
|
def health_check():
|
||||||
|
logger.debug("Health check requested")
|
||||||
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=8080, debug=True)
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import musicbrainzngs
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from .config import config
|
||||||
|
from datetime import datetime
|
||||||
|
from email.utils import formatdate
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def init_musicbrainz():
|
||||||
|
app_name = config.MB_APP_NAME
|
||||||
|
version = config.MB_VERSION
|
||||||
|
contact = config.MB_CONTACT
|
||||||
|
|
||||||
|
logger.debug(f"Initializing MusicBrainz API with user agent: {app_name}/{version} ( {contact} )")
|
||||||
|
musicbrainzngs.set_useragent(app_name, version, contact)
|
||||||
|
|
||||||
|
def search_artists(query):
|
||||||
|
logger.debug(f"Searching for artists with query: '{query}'")
|
||||||
|
try:
|
||||||
|
result = musicbrainzngs.search_artists(query=query, limit=10)
|
||||||
|
artists = []
|
||||||
|
if 'artist-list' in result:
|
||||||
|
for artist in result['artist-list']:
|
||||||
|
artists.append({
|
||||||
|
'id': artist['id'],
|
||||||
|
'name': artist['name'],
|
||||||
|
'disambiguation': artist.get('disambiguation', '')
|
||||||
|
})
|
||||||
|
logger.debug(f"Found {len(artists)} artists for query: '{query}'")
|
||||||
|
return artists
|
||||||
|
except musicbrainzngs.MusicBrainzError as e:
|
||||||
|
logger.error(f"MusicBrainz API error while searching for '{query}': {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _parse_release_date(release_date_str, release_title):
|
||||||
|
"""Parse a release date string which can be YYYY, YYYY-MM, or YYYY-MM-DD."""
|
||||||
|
if not release_date_str:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
date_formats = ['%Y-%m-%d', '%Y-%m', '%Y']
|
||||||
|
dt = None
|
||||||
|
for fmt in date_formats:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(release_date_str, fmt)
|
||||||
|
if fmt == '%Y-%m':
|
||||||
|
dt = dt.replace(day=1)
|
||||||
|
elif fmt == '%Y':
|
||||||
|
dt = dt.replace(month=1, day=1)
|
||||||
|
break # Found a valid format
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if dt:
|
||||||
|
return dt, formatdate(dt.timestamp())
|
||||||
|
|
||||||
|
logger.warning(f"Could not parse date format '{release_date_str}' for release '{release_title}'")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def _process_release(release, artist_id):
|
||||||
|
"""Process a single release from the MusicBrainz API response."""
|
||||||
|
release_date = release.get('date', 'Unknown')
|
||||||
|
_, pub_date = _parse_release_date(release_date, release['title'])
|
||||||
|
|
||||||
|
return {
|
||||||
|
'artist': {
|
||||||
|
'name': config.get_artist_name(artist_id),
|
||||||
|
'id': artist_id,
|
||||||
|
},
|
||||||
|
'hasCoverArt': release.get('cover-art-archive', {}).get('artwork') == 'true',
|
||||||
|
'id': release['id'],
|
||||||
|
'title': release['title'],
|
||||||
|
'date': release_date,
|
||||||
|
'pub_date': pub_date,
|
||||||
|
'release-group': release.get('release-group', {})
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_artist_releases(artist_id):
|
||||||
|
"""Fetch releases for a given artist ID from MusicBrainz."""
|
||||||
|
artist_name = config.get_artist_name(artist_id)
|
||||||
|
logger.debug(f"Fetching releases for artist {artist_name} ({artist_id})")
|
||||||
|
try:
|
||||||
|
result = musicbrainzngs.browse_releases(
|
||||||
|
artist=artist_id,
|
||||||
|
release_type=['album'],
|
||||||
|
includes=['release-groups']
|
||||||
|
)
|
||||||
|
|
||||||
|
releases = []
|
||||||
|
if 'release-list' in result:
|
||||||
|
releases = [_process_release(r, artist_id) for r in result['release-list']]
|
||||||
|
|
||||||
|
logger.debug(f"Found {len(releases)} releases for artist {artist_name} ({artist_id})")
|
||||||
|
return releases
|
||||||
|
except musicbrainzngs.MusicBrainzError as e:
|
||||||
|
logger.error(f"MusicBrainz API error while fetching releases for artist '{artist_id}': {e}")
|
||||||
|
return []
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>MusicBrainz RSS Feeder</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
background-color: #fdfdfd;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
header h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
header h1 a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
nav a {
|
||||||
|
margin-left: 20px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
h2, h3 {
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
ul {
|
||||||
|
list-style-type: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
form {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background-color: #333;
|
||||||
|
color: white;
|
||||||
|
padding: 8px 15px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
background-color: #555;
|
||||||
|
}
|
||||||
|
.form-inline {
|
||||||
|
display: inline;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.item-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1><a href="{{ url_for('index') }}">MusicBrainz RSS Feeder</a></h1>
|
||||||
|
<nav>
|
||||||
|
<a href="{{ url_for('index') }}">Feeds</a>
|
||||||
|
<a href="{{ url_for('settings') }}">Settings</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>Edit Feed: {{ feed.name }}</h2>
|
||||||
|
|
||||||
|
<h3>Artists in this feed</h3>
|
||||||
|
<ul>
|
||||||
|
{% for artist in feed.artists %}
|
||||||
|
<li>
|
||||||
|
{{ artist.name }}
|
||||||
|
<div class="item-actions">
|
||||||
|
<form class="form-inline" action="{{ url_for('remove_artist', feed_id=feed.id, artist_id=artist.id) }}" method="post">
|
||||||
|
<button type="submit">Remove</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li>No artists in this feed.</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Add Artist</h3>
|
||||||
|
<form onsubmit="return false;">
|
||||||
|
<input type="text" id="artist-search" placeholder="Search for an artist...">
|
||||||
|
</form>
|
||||||
|
<div id="search-results"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const searchInput = document.getElementById('artist-search');
|
||||||
|
const resultsDiv = document.getElementById('search-results');
|
||||||
|
let searchTimeout;
|
||||||
|
|
||||||
|
searchInput.addEventListener('input', () => {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
const query = searchInput.value;
|
||||||
|
if (query.length < 3) {
|
||||||
|
resultsDiv.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
fetch(`{{ url_for('search_artist') }}?q=${encodeURIComponent(query)}`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(artists => {
|
||||||
|
resultsDiv.innerHTML = '<h4>Search Results</h4>';
|
||||||
|
if (artists.length === 0) {
|
||||||
|
resultsDiv.innerHTML += '<p>No artists found.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const list = document.createElement('ul');
|
||||||
|
artists.forEach(artist => {
|
||||||
|
const item = document.createElement('li');
|
||||||
|
item.innerHTML = `
|
||||||
|
<div>
|
||||||
|
${artist.name} (${artist.country || 'N/A'})
|
||||||
|
</div>
|
||||||
|
<div class="item-actions">
|
||||||
|
<form class="form-inline" action="{{ url_for('add_artist', feed_id=feed.id) }}" method="post">
|
||||||
|
<input type="hidden" name="artist_id" value="${artist.id}">
|
||||||
|
<input type="hidden" name="artist_name" value="${artist.name}">
|
||||||
|
<button type="submit">Add</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
list.appendChild(item);
|
||||||
|
});
|
||||||
|
resultsDiv.appendChild(list);
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||||
|
<channel>
|
||||||
|
<title>{{ feed.name }}</title>
|
||||||
|
<link>{{ request.url_root }}</link>
|
||||||
|
<description>Latest album releases for artists in the '{{ feed.name }}' feed.</description>
|
||||||
|
<language>en</language>
|
||||||
|
<lastBuildDate>{{ last_build_date }}</lastBuildDate>
|
||||||
|
<atom:link href="{{ request.url }}" rel="self" type="application/rss+xml" />
|
||||||
|
|
||||||
|
{% for release in releases %}
|
||||||
|
<item>
|
||||||
|
<title>{{ release.artist.name }} - {{ release.title }}</title>
|
||||||
|
<link>https://musicbrainz.org/release/{{ release.id }}</link>
|
||||||
|
<guid isPermaLink="false">{{ release.id }}</guid>
|
||||||
|
<description><![CDATA[
|
||||||
|
<p><b>{{ release.artist.name }}</b> <i>{{ release.title }}</i><br>
|
||||||
|
Release Date: {{ release.date }}</p>
|
||||||
|
{% if release.hasCoverArt %}
|
||||||
|
<img src="http://coverartarchive.org/release/{{ release.id }}/front-500" alt="Cover Art {{ release.title }}">
|
||||||
|
{% endif %}
|
||||||
|
]]></description>
|
||||||
|
{% if release.pub_date %}
|
||||||
|
<pubDate>{{ release.pub_date }}</pubDate>
|
||||||
|
{% endif %}
|
||||||
|
</item>
|
||||||
|
{% endfor %}
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>Feeds</h2>
|
||||||
|
<ul>
|
||||||
|
{% for feed in feeds %}
|
||||||
|
<li>
|
||||||
|
<div>
|
||||||
|
<a href="{{ url_for('edit_feed', feed_id=feed.id) }}">{{ feed.name }}</a>
|
||||||
|
({{ feed.artists|length }})
|
||||||
|
</div>
|
||||||
|
<div class="item-actions">
|
||||||
|
<a href="{{ url_for('get_feed_rss', feed_id=feed.id) }}">RSS</a>
|
||||||
|
<form class="form-inline" action="{{ url_for('delete_feed', feed_id=feed.id) }}" method="post">
|
||||||
|
<button type="submit">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li>No feeds configured.</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Create New Feed</h3>
|
||||||
|
<form action="{{ url_for('create_feed') }}" method="post">
|
||||||
|
<label for="name">Feed Name:</label>
|
||||||
|
<input type="text" id="name" name="name" required>
|
||||||
|
<button type="submit">Create</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<opml version="2.0">
|
||||||
|
<head>
|
||||||
|
<title>MusicBrainz Artist Feeds</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<outline text="MusicBrainz Artist Feeds" title="MusicBrainz Artist Feeds">
|
||||||
|
{% for feed in feeds %}
|
||||||
|
<outline type="rss" text="{{ (feed.artists | map(attribute='name') | sort | join(', ')) }}" title="{{ feed.name }} ({{ feed.artists | length }} artists)" xmlUrl="{{ url_for('get_feed_rss', feed_id=feed.id, _external=True) }}"/>
|
||||||
|
{% endfor %}
|
||||||
|
</outline>
|
||||||
|
</body>
|
||||||
|
</opml>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<form method="post">
|
||||||
|
<div>
|
||||||
|
<label for="days_back">Days to look back for releases:</label>
|
||||||
|
<input type="number" id="days_back" name="days_back" value="{{ settings.days_back }}" required>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<div>
|
||||||
|
<label for="cache_time_hours">Cache time (hours):</label>
|
||||||
|
<input type="number" id="cache_time_hours" name="cache_time_hours" value="{{ settings.cache_time_hours }}" required>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<button type="submit">Save Settings</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Flask
|
||||||
|
PyYAML
|
||||||
|
musicbrainzngs
|
||||||
|
feedgen
|
||||||
|
gunicorn
|
||||||
|
pytest
|
||||||
|
pytz
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
Flask
|
||||||
|
PyYAML
|
||||||
|
musicbrainzngs
|
||||||
|
feedgen
|
||||||
|
gunicorn
|
||||||
|
pytz
|
||||||
Reference in New Issue
Block a user