Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 783581ffe4 | |||
| a11ec95e58 | |||
| 93eef59aa8 | |||
| 6efa6617d3 | |||
| c97d22e0f0 | |||
| 4244f98584 | |||
| d3c8824c42 | |||
| d4d879cf55 |
+3
-3
@@ -26,7 +26,7 @@ RUN mkdir -p /var/mbz-rss-feeder/cache /var/mbz-rss-feeder/log /var/mbz-rss-feed
|
|||||||
|
|
||||||
# Test stage
|
# Test stage
|
||||||
FROM builder AS test
|
FROM builder AS test
|
||||||
COPY mzb_rss_service/tests/requirements.txt .
|
COPY mbz_rss_service/tests/requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY . .
|
COPY . .
|
||||||
CMD ["python -m pytest"]
|
CMD ["python -m pytest"]
|
||||||
@@ -35,8 +35,8 @@ CMD ["python -m pytest"]
|
|||||||
FROM base AS final
|
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/lib/python3.10/site-packages /usr/local/lib/python3.10/site-packages
|
||||||
COPY --from=builder /usr/local/bin /usr/local/bin
|
COPY --from=builder /usr/local/bin /usr/local/bin
|
||||||
COPY mzb_rss_service mzb_rss_service
|
COPY mbz_rss_service mbz_rss_service
|
||||||
COPY VERSION .
|
COPY VERSION .
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "mzb_rss_service.main:app"]
|
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "mbz_rss_service.main:app"]
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ This is a local container running with data stored in ./local/testdata
|
|||||||
-p 8080:8080 -e LOG_LEVEL=debug --rm --name mbz-rss\
|
-p 8080:8080 -e LOG_LEVEL=debug --rm --name mbz-rss\
|
||||||
-v ./local/testdata/:/var/mbz-rss-feeder \
|
-v ./local/testdata/:/var/mbz-rss-feeder \
|
||||||
--env-file ./local/testdata/.env \
|
--env-file ./local/testdata/.env \
|
||||||
docker.io/library/mzb-rss-feeder:latest`
|
docker.io/library/mbz-rss-feeder:latest`
|
||||||
|
|
||||||
After startup the server can be accessed through http://docker-host:8080.
|
After startup the server can be accessed through http://docker-host:8080.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
# mbz_rss_service package
|
||||||
@@ -11,9 +11,10 @@ logger = logging.getLogger(__name__)
|
|||||||
FEEDS_FILE_PATH = os.path.expandvars(os.environ.get('FEEDS_FILE_PATH', '/var/mbz-rss-feeder/feeds.yml'))
|
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', '/var/mbz-rss-feeder/etc/mbz-rss-feeder.yml'))
|
CONFIG_FILE_PATH = os.path.expandvars(os.environ.get('CONFIG_FILE_PATH', '/var/mbz-rss-feeder/etc/mbz-rss-feeder.yml'))
|
||||||
CACHE_DIR = os.path.expandvars(os.environ.get('CACHE_DIR', '/var/mbz-rss-feeder/cache'))
|
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_APP_NAME = os.path.expandvars(os.environ.get('MB_APP_NAME', 'mbz-rss-service'))
|
||||||
MB_VERSION = os.path.expandvars(os.environ.get('MB_VERSION', '0.1.0'))
|
MB_VERSION = os.path.expandvars(os.environ.get('MB_VERSION', '1'))
|
||||||
MB_CONTACT = os.path.expandvars(os.environ.get('MB_CONTACT', 'someone@somewhere.com'))
|
MB_CONTACT = os.path.expandvars(os.environ.get('MB_CONTACT', 'someone@somewhere.com'))
|
||||||
|
MBZ_SERVICE_BASE_URL = os.path.expandvars(os.environ.get('MBZ_SERVICE_BASE_URL', 'http://mbz-rss-feeder:8080'))
|
||||||
|
|
||||||
# file system dir check
|
# file system dir check
|
||||||
if not os.path.exists(CACHE_DIR):
|
if not os.path.exists(CACHE_DIR):
|
||||||
@@ -41,6 +42,8 @@ class Config:
|
|||||||
self.MB_APP_NAME = MB_APP_NAME
|
self.MB_APP_NAME = MB_APP_NAME
|
||||||
self.MB_VERSION = MB_VERSION
|
self.MB_VERSION = MB_VERSION
|
||||||
self.MB_CONTACT = MB_CONTACT
|
self.MB_CONTACT = MB_CONTACT
|
||||||
|
self.MBZ_SERVICE_BASE_URL = MBZ_SERVICE_BASE_URL
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(os.path.join(os.path.dirname(__file__), '..', 'VERSION')) as f:
|
with open(os.path.join(os.path.dirname(__file__), '..', 'VERSION')) as f:
|
||||||
self.VERSION = f.read().strip()
|
self.VERSION = f.read().strip()
|
||||||
@@ -68,10 +71,13 @@ class Config:
|
|||||||
backup_path = f"{file_path}.{datetime.now().strftime('%Y%m%d%H%M%S')}.bak"
|
backup_path = f"{file_path}.{datetime.now().strftime('%Y%m%d%H%M%S')}.bak"
|
||||||
shutil.copy(file_path, backup_path)
|
shutil.copy(file_path, backup_path)
|
||||||
|
|
||||||
|
logger.debug(f"Saving yaml to {file_path}")
|
||||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||||
with open(file_path, 'w') as f:
|
with open(file_path, 'w') as f:
|
||||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||||
|
|
||||||
|
logger.debug(f"Saved yaml to {file_path}")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def feeds(self):
|
def feeds(self):
|
||||||
return self._feeds_data.get('feeds', [])
|
return self._feeds_data.get('feeds', [])
|
||||||
@@ -105,13 +111,16 @@ class Config:
|
|||||||
self._feeds_data['feeds'] = [feed for feed in self.feeds if feed['id'] != feed_id]
|
self._feeds_data['feeds'] = [feed for feed in self.feeds if feed['id'] != feed_id]
|
||||||
self.save_feeds()
|
self.save_feeds()
|
||||||
|
|
||||||
def add_artist_to_feed(self, feed_id, artist_id, artist_name):
|
def add_artist_to_feed(self, feed_id, artist_id, artist_name, links = None):
|
||||||
for feed in self.feeds:
|
for feed in self.feeds:
|
||||||
if feed['id'] == feed_id:
|
if feed['id'] == feed_id:
|
||||||
if 'artists' not in feed:
|
if 'artists' not in feed:
|
||||||
feed['artists'] = []
|
feed['artists'] = []
|
||||||
if not any(a['id'] == artist_id for a in feed['artists']):
|
if not any(a['id'] == artist_id for a in feed['artists']):
|
||||||
feed['artists'].append({'id': artist_id, 'name': artist_name})
|
artist_data = {'id': artist_id, 'name': artist_name}
|
||||||
|
if links:
|
||||||
|
artist_data['links'] = links
|
||||||
|
feed['artists'].append(artist_data)
|
||||||
feed['updated_at'] = datetime.now(timezone.utc).isoformat()
|
feed['updated_at'] = datetime.now(timezone.utc).isoformat()
|
||||||
self.save_feeds()
|
self.save_feeds()
|
||||||
break
|
break
|
||||||
@@ -9,6 +9,8 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
from . import musicbrainz
|
from . import musicbrainz
|
||||||
|
|
||||||
|
XML_CONTENT_TYPE = "application/xml"
|
||||||
|
|
||||||
log_level_str = os.environ.get('LOG_LEVEL', 'INFO').upper()
|
log_level_str = os.environ.get('LOG_LEVEL', 'INFO').upper()
|
||||||
log_level = getattr(logging, log_level_str, logging.INFO)
|
log_level = getattr(logging, log_level_str, logging.INFO)
|
||||||
log_file = os.path.expandvars(os.environ.get('LOG_FILE', '/var/mbz-rss-feeder/log/mbz-rss-feeder.log'))
|
log_file = os.path.expandvars(os.environ.get('LOG_FILE', '/var/mbz-rss-feeder/log/mbz-rss-feeder.log'))
|
||||||
@@ -55,7 +57,7 @@ for handler in werkzeug_logger.handlers:
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
logger.info("Starting mzb-rss-service with the following configuration:")
|
logger.info(f"Starting mbz-rss-service v{config.VERSION} with the following configuration:")
|
||||||
logger.info(f" - Log Level: {log_level_str}")
|
logger.info(f" - Log Level: {log_level_str}")
|
||||||
logger.info(f" - Log File: {log_file}")
|
logger.info(f" - Log File: {log_file}")
|
||||||
logger.info(f" - Feeds File: {config.FEEDS_FILE_PATH}")
|
logger.info(f" - Feeds File: {config.FEEDS_FILE_PATH}")
|
||||||
@@ -69,6 +71,30 @@ logger.info(f" - MusicBrainz Log Level: {mbz_log_level_str}")
|
|||||||
# init
|
# init
|
||||||
musicbrainz.init_musicbrainz()
|
musicbrainz.init_musicbrainz()
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def inject_service_name():
|
||||||
|
return {'service_base_url': config.MBZ_SERVICE_BASE_URL}
|
||||||
|
|
||||||
|
class ReverseProxied(object):
|
||||||
|
def __init__(self, app):
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
def __call__(self, environ, start_response):
|
||||||
|
script_name = environ.get('HTTP_X_FORWARDED_PREFIX', '')
|
||||||
|
if script_name:
|
||||||
|
logger.debug(f"Redirect prefix: {script_name}")
|
||||||
|
environ['SCRIPT_NAME'] = script_name
|
||||||
|
path_info = environ['PATH_INFO']
|
||||||
|
if path_info.startswith(script_name):
|
||||||
|
environ['PATH_INFO'] = path_info[len(script_name):]
|
||||||
|
else:
|
||||||
|
logger.debug("No redirect prefix detected")
|
||||||
|
|
||||||
|
return self.app(environ, start_response)
|
||||||
|
|
||||||
|
app.wsgi_app = ReverseProxied(app.wsgi_app)
|
||||||
|
|
||||||
app.template_folder = 'templates'
|
app.template_folder = 'templates'
|
||||||
|
|
||||||
|
|
||||||
@@ -134,7 +160,7 @@ def _check_cache(feed_id):
|
|||||||
try:
|
try:
|
||||||
with open(cache_file, 'r') as f:
|
with open(cache_file, 'r') as f:
|
||||||
logger.debug(f"Serving cached feed for {feed_id}")
|
logger.debug(f"Serving cached feed for {feed_id}")
|
||||||
return f.read(), {"Content-Type": "application/xml"}
|
return f.read(), {"Content-Type": XML_CONTENT_TYPE}
|
||||||
except IOError as e:
|
except IOError as e:
|
||||||
logger.warning(f"Could not read cache file {cache_file}: {e}")
|
logger.warning(f"Could not read cache file {cache_file}: {e}")
|
||||||
|
|
||||||
@@ -175,7 +201,8 @@ def add_artist(feed_id):
|
|||||||
artist_name = request.form.get('artist_name')
|
artist_name = request.form.get('artist_name')
|
||||||
logger.debug(f"Request to add artist '{artist_name}' ({artist_id}) to feed {feed_id}")
|
logger.debug(f"Request to add artist '{artist_name}' ({artist_id}) to feed {feed_id}")
|
||||||
if artist_id and artist_name:
|
if artist_id and artist_name:
|
||||||
config.add_artist_to_feed(feed_id, artist_id, artist_name)
|
meta = musicbrainz.get_artist_meta_by_id(artist_id)
|
||||||
|
config.add_artist_to_feed(feed_id, artist_id, artist_name, meta['links'])
|
||||||
return redirect(url_for('edit_feed', feed_id=feed_id))
|
return redirect(url_for('edit_feed', feed_id=feed_id))
|
||||||
|
|
||||||
@app.route("/feed/<feed_id>/artist/<artist_id>/remove", methods=["POST"])
|
@app.route("/feed/<feed_id>/artist/<artist_id>/remove", methods=["POST"])
|
||||||
@@ -208,7 +235,7 @@ def opml():
|
|||||||
logger.debug("Request for OPML file")
|
logger.debug("Request for OPML file")
|
||||||
feeds = config.feeds
|
feeds = config.feeds
|
||||||
opml_content = render_template('opml.xml', feeds=feeds)
|
opml_content = render_template('opml.xml', feeds=feeds)
|
||||||
return opml_content, {"Content-Type": "application/xml"}
|
return opml_content, {"Content-Type": XML_CONTENT_TYPE}
|
||||||
|
|
||||||
@app.route('/feed/<feed_id>')
|
@app.route('/feed/<feed_id>')
|
||||||
def get_feed_rss(feed_id):
|
def get_feed_rss(feed_id):
|
||||||
@@ -257,7 +284,7 @@ def get_feed_rss(feed_id):
|
|||||||
except IOError as e:
|
except IOError as e:
|
||||||
logger.warning(f"Could not write feed '{feed_data['name']}' to cache file {cache_file}: {e}")
|
logger.warning(f"Could not write feed '{feed_data['name']}' to cache file {cache_file}: {e}")
|
||||||
|
|
||||||
return rss_content, {"Content-Type": "application/xml"}
|
return rss_content, {"Content-Type": XML_CONTENT_TYPE}
|
||||||
|
|
||||||
@app.route("/health")
|
@app.route("/health")
|
||||||
def health_check():
|
def health_check():
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import re
|
||||||
import musicbrainzngs
|
import musicbrainzngs
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
from .config import config
|
from .config import config
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from email.utils import formatdate
|
from email.utils import formatdate
|
||||||
@@ -62,6 +62,10 @@ def _process_release(release, artist_id):
|
|||||||
release_date = release.get('date', 'Unknown')
|
release_date = release.get('date', 'Unknown')
|
||||||
_, pub_date = _parse_release_date(release_date, release['title'])
|
_, pub_date = _parse_release_date(release_date, release['title'])
|
||||||
|
|
||||||
|
links = {}
|
||||||
|
if 'url-relation-list' in release:
|
||||||
|
links = get_artist_relation_links(release['url-relation-list'])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'artist': {
|
'artist': {
|
||||||
'name': config.get_artist_name(artist_id),
|
'name': config.get_artist_name(artist_id),
|
||||||
@@ -72,8 +76,54 @@ def _process_release(release, artist_id):
|
|||||||
'title': release['title'],
|
'title': release['title'],
|
||||||
'date': release_date,
|
'date': release_date,
|
||||||
'pub_date': pub_date,
|
'pub_date': pub_date,
|
||||||
'release-group': release.get('release-group', {})
|
'release-group': release.get('release-group', {}),
|
||||||
|
'links': links,
|
||||||
}
|
}
|
||||||
|
def get_artist_meta_by_id(artist_id):
|
||||||
|
"""get additional meta data about an artist"""
|
||||||
|
meta = {
|
||||||
|
'links': {}
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
logger.debug(f"Fetching artist {artist_id} meta data")
|
||||||
|
# Fetch artist data including URL relations
|
||||||
|
result = musicbrainzngs.get_artist_by_id(artist_id, includes=["url-rels"])
|
||||||
|
artist_data = result.get('artist', {})
|
||||||
|
|
||||||
|
if 'url-relation-list' in artist_data:
|
||||||
|
meta['links'] = get_artist_relation_links(artist_data['url-relation-list'])
|
||||||
|
else:
|
||||||
|
meta['links'] = None
|
||||||
|
|
||||||
|
return meta
|
||||||
|
|
||||||
|
except musicbrainzngs.WebServiceError as exc:
|
||||||
|
logger.error(f"Error fetching artist {artist_id} meta data from MusicBrainz: {exc}")
|
||||||
|
|
||||||
|
return meta
|
||||||
|
|
||||||
|
def get_artist_relation_links(relationlist):
|
||||||
|
"""Parse and extract named links from a MusicBrainz URL relation list."""
|
||||||
|
links = {}
|
||||||
|
for rel in relationlist:
|
||||||
|
target = rel.get('target')
|
||||||
|
if not target:
|
||||||
|
continue
|
||||||
|
if re.match(r'^https://([^/]+\.)?imdb\.com', target):
|
||||||
|
links['IMDb'] = target
|
||||||
|
elif re.match(r'^https://music\.apple\.com', target):
|
||||||
|
links['apple'] = target
|
||||||
|
elif re.match(r'^https://music\.amazon\.com', target):
|
||||||
|
links['amazon'] = target
|
||||||
|
elif re.match(r'^https://open\.spotify\.com', target):
|
||||||
|
links['spotify'] = target
|
||||||
|
elif re.match(r'^https://([^/]+\.)?qobuz\.com', target):
|
||||||
|
links['qobuz'] = target
|
||||||
|
elif re.match(r'^https://([^/]+\.)?deezer\.com', target):
|
||||||
|
links['deezer'] = target
|
||||||
|
elif re.match(r'^https://([^/]+\.)?beatport\.com', target):
|
||||||
|
links['beatport'] = target
|
||||||
|
return links
|
||||||
|
|
||||||
def get_artist_releases(artist_id):
|
def get_artist_releases(artist_id):
|
||||||
"""Fetch releases for a given artist ID from MusicBrainz."""
|
"""Fetch releases for a given artist ID from MusicBrainz."""
|
||||||
@@ -83,7 +133,7 @@ def get_artist_releases(artist_id):
|
|||||||
result = musicbrainzngs.browse_releases(
|
result = musicbrainzngs.browse_releases(
|
||||||
artist=artist_id,
|
artist=artist_id,
|
||||||
release_type=['album'],
|
release_type=['album'],
|
||||||
includes=['release-groups']
|
includes=['release-groups', 'url-rels']
|
||||||
)
|
)
|
||||||
|
|
||||||
releases = []
|
releases = []
|
||||||
@@ -9,6 +9,11 @@
|
|||||||
{% for artist in feed.artists %}
|
{% for artist in feed.artists %}
|
||||||
<li>
|
<li>
|
||||||
{{ artist.name }}
|
{{ artist.name }}
|
||||||
|
{% if artist.links %}
|
||||||
|
{% for type, link in artist.links.items() %}
|
||||||
|
<a href="{{ link }}" target="_blank">{{ type }}</a>{% if not loop.last %} | {% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
<div class="item-actions">
|
<div class="item-actions">
|
||||||
<form class="form-inline" action="{{ url_for('remove_artist', feed_id=feed.id, artist_id=artist.id) }}" method="post">
|
<form class="form-inline" action="{{ url_for('remove_artist', feed_id=feed.id, artist_id=artist.id) }}" method="post">
|
||||||
<button type="submit">Remove</button>
|
<button type="submit">Remove</button>
|
||||||
@@ -14,10 +14,17 @@
|
|||||||
<link>https://musicbrainz.org/release/{{ release.id }}</link>
|
<link>https://musicbrainz.org/release/{{ release.id }}</link>
|
||||||
<guid isPermaLink="false">{{ release.id }}</guid>
|
<guid isPermaLink="false">{{ release.id }}</guid>
|
||||||
<description><![CDATA[
|
<description><![CDATA[
|
||||||
|
{% if release.hasCoverArt %}
|
||||||
|
<img src="https://coverartarchive.org/release/{{ release.id }}/front" alt="Cover Art {{ release.title }}">
|
||||||
|
{% endif %}
|
||||||
<p><b>{{ release.artist.name }}</b> <i>{{ release.title }}</i><br>
|
<p><b>{{ release.artist.name }}</b> <i>{{ release.title }}</i><br>
|
||||||
Release Date: {{ release.date }}</p>
|
Release Date: {{ release.date }}</p>
|
||||||
{% if release.hasCoverArt %}
|
{% if release.links %}
|
||||||
<img src="http://coverartarchive.org/release/{{ release.id }}/front-500" alt="Cover Art {{ release.title }}">
|
<p><b>Links: </b>
|
||||||
|
{% for type, url in release.links.items() %}
|
||||||
|
<a href="{{ url }}" target="_blank">{{ type | capitalize }}</a>{% if not loop.last %} | {% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
]]></description>
|
]]></description>
|
||||||
{% if release.pub_date %}
|
{% if release.pub_date %}
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
({{ feed.artists|length }})
|
({{ feed.artists|length }})
|
||||||
</div>
|
</div>
|
||||||
<div class="item-actions">
|
<div class="item-actions">
|
||||||
<a href="{{ url_for('get_feed_rss', feed_id=feed.id) }}">RSS</a>
|
<a href="{{ url_for('get_feed_rss', feed_id=feed.id) }}">RSS</a> | <a href="{{ service_base_url }}/feed/{{ feed.id }}">service rss</a>
|
||||||
<form class="form-inline" action="{{ url_for('delete_feed', feed_id=feed.id) }}" method="post">
|
<form class="form-inline" action="{{ url_for('delete_feed', feed_id=feed.id) }}" method="post">
|
||||||
<button type="submit">Delete</button>
|
<button type="submit">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -1 +0,0 @@
|
|||||||
# mzb_rss_service package
|
|
||||||
Reference in New Issue
Block a user