add related links

artist links to feed page (/feed/:ID)
release links to feed xml (release description)
This commit is contained in:
Volker
2025-12-27 17:11:17 +01:00
parent 6efa6617d3
commit 93eef59aa8
5 changed files with 82 additions and 11 deletions
+8 -2
View File
@@ -68,10 +68,13 @@ class Config:
backup_path = f"{file_path}.{datetime.now().strftime('%Y%m%d%H%M%S')}.bak"
shutil.copy(file_path, backup_path)
logger.debug(f"Saving yaml to {file_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)
logger.debug(f"Saved yaml to {file_path}")
@property
def feeds(self):
return self._feeds_data.get('feeds', [])
@@ -105,13 +108,16 @@ class Config:
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):
def add_artist_to_feed(self, feed_id, artist_id, artist_name, links = None):
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})
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()
self.save_feeds()
break
+7 -4
View File
@@ -9,6 +9,8 @@ import os
import sys
from . import musicbrainz
XML_CONTENT_TYPE = "application/xml"
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/mbz-rss-feeder/log/mbz-rss-feeder.log'))
@@ -154,7 +156,7 @@ def _check_cache(feed_id):
try:
with open(cache_file, 'r') as f:
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:
logger.warning(f"Could not read cache file {cache_file}: {e}")
@@ -195,7 +197,8 @@ def add_artist(feed_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)
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))
@app.route("/feed/<feed_id>/artist/<artist_id>/remove", methods=["POST"])
@@ -228,7 +231,7 @@ 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"}
return opml_content, {"Content-Type": XML_CONTENT_TYPE}
@app.route('/feed/<feed_id>')
def get_feed_rss(feed_id):
@@ -277,7 +280,7 @@ def get_feed_rss(feed_id):
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"}
return rss_content, {"Content-Type": XML_CONTENT_TYPE}
@app.route("/health")
def health_check():
+147
View File
@@ -0,0 +1,147 @@
import re
import musicbrainzngs
import logging
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'])
links = {}
if 'url-relation-list' in release:
links = get_artist_relation_links(release['url-relation-list'])
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', {}),
'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):
"""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', 'url-rels']
)
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 []
+77
View File
@@ -0,0 +1,77 @@
{% 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 }}
{% 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">
<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 %}
+36
View File
@@ -0,0 +1,36 @@
<?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[
{% 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>
Release Date: {{ release.date }}</p>
{% if release.links %}
<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 %}
]]></description>
{% if release.pub_date %}
<pubDate>{{ release.pub_date }}</pubDate>
{% endif %}
</item>
{% endfor %}
</channel>
</rss>