import sqlite3
import threading
#logging functionality added
import logging
import asyncio
import tomllib
import time
#Moved import to front
from datetime import datetime
from flask import Flask, render_template, jsonify
from flasgger import Swagger
from news_at_12 import fetch_all, get_db, log_run_summary, export_json, save_html, load_config
#Load config doesn't need to be here
app = Flask(__name__)
CONFIG_FILE = 'config.toml'
# ── Flasgger / Swagger UI config ──────────────────────────────────────────────
app.config['SWAGGER'] = {
'title': 'News_at_12 API',
'description': 'REST API for the News_at_12 RSS headline aggregator.',
'version': '1.0.0',
'uiversion': 3,
}
Swagger(app)
# ── Shared run-state ──────────────────────────────────────────────────────────
_run_lock = threading.Lock()
_is_running = False
_is_running_ref = [False] # mutable reference shared with the API blueprint
# ── Register API blueprint ────────────────────────────────────────────────────
from api import api as api_blueprint, init_api
app.register_blueprint(api_blueprint)
init_api(_run_lock, _is_running_ref)
[docs]
def get_connection():
"""Open a SQLite connection using the database path from config.toml.
Reads ``db_file`` from the ``[settings]`` section of ``config.toml``.
Falls back to ``'headlines.db'`` if the config cannot be loaded.
Returns:
tuple[sqlite3.Connection, str]: A two-element tuple containing:
- An open database connection with ``row_factory`` set to
``sqlite3.Row`` for dict-style column access.
- The resolved database file path as a string.
"""
settings = load_config(CONFIG_FILE)
if settings:
db_file = settings['settings'].get('db_file', 'headlines.db')
else:
db_file = 'headlines.db'
conn = sqlite3.connect(db_file)
conn.row_factory = sqlite3.Row
return conn, db_file
def _run_aggregator():
"""Run the full aggregation pipeline in a background thread.
Intended to be called via ``threading.Thread`` — never directly.
Loads config, fetches all enabled feeds concurrently, writes results
to the database, logs the run summary, and exports a JSON snapshot.
Resets ``_is_running`` and ``_is_running_ref[0]`` to ``False`` on
completion, whether the run succeeded or failed.
If config is missing or contains no enabled feeds, the function
exits early and resets the running flag without fetching anything.
"""
global _is_running
config = load_config(CONFIG_FILE)
if not config or not config['feeds']:
with _run_lock:
_is_running = False
_is_running_ref[0] = False
return
settings = config['settings']
feeds = config['feeds']
feed_urls = [fd['url'] for fd in feeds]
db_file = settings.get('db_file', 'headlines.db')
max_workers = settings.get('max_workers', 10)
summary_limit = settings.get('summary_limit', 300)
#html_output = settings.get('html_output', 'headlines.html')
json_output = settings.get('json_output', 'headlines.json')
auto_open = False
conn = get_db(db_file)
started_at = datetime.now().isoformat()
t_start = time.monotonic()
try:
all_feeds_data = asyncio.run(fetch_all(feed_urls, conn, max_workers, summary_limit))
except Exception:
#This was eating up the news_at_12 script's errors.
#If the script dies, no errors would be shown.
logging.exception(f"We've got some news for you, coming right up,\n news_at_12 has been found dead")
all_feeds_data = []
elapsed = time.monotonic() - t_start
finished_at = datetime.now().isoformat()
total = sum(len(f['entries']) for f in all_feeds_data)
total_new = sum(f['new_count'] for f in all_feeds_data)
feeds_failed = len(feed_urls) - len(all_feeds_data)
log_run_summary(
conn,
started_at = started_at,
finished_at = finished_at,
elapsed_sec = round(elapsed, 3),
feeds_fetched = len(all_feeds_data),
feeds_failed = feeds_failed,
articles_total = total,
articles_new = total_new,
)
conn.close()
#save_html(all_feeds_data, html_output, elapsed, db_file, auto_open_browser=auto_open)
export_json(all_feeds_data, json_output)
with _run_lock:
_is_running = False
_is_running_ref[0] = False
[docs]
@app.route('/')
def index():
"""Render the main headlines page.
Queries all headlines joined with their parent feed, groups them by
feed title in Python, and passes the grouped structure to the
``index.html`` template.
Returns:
str: Rendered HTML response for the ``/`` route.
"""
conn, _ = get_connection()
rows = conn.execute("""
SELECT
h.title,
h.url,
h.published,
h.summary,
h.first_seen,
h.last_seen,
h.seen_count,
f.title AS feed_title,
f.url AS feed_url,
f.site_link
FROM headlines h
JOIN feeds f ON f.id = h.feed_id
ORDER BY f.title ASC, h.first_seen DESC
""").fetchall()
conn.close()
groups = {}
for row in rows:
r = dict(row)
key = r['feed_title']
if key not in groups:
groups[key] = {
'feed_title': r['feed_title'],
'feed_url': r['feed_url'],
'site_link': r['site_link'],
'articles': [],
}
groups[key]['articles'].append(r)
feed_groups = list(groups.values())
total = sum(len(g['articles']) for g in feed_groups)
return render_template('index.html', feed_groups=feed_groups,
total=total, is_running=_is_running)
[docs]
@app.route('/feeds')
def feeds():
"""Render the feeds page listing all RSS sources with headline counts.
Queries all feeds with a count of their associated headlines using a
LEFT JOIN so feeds with zero articles still appear.
Returns:
str: Rendered HTML response for the ``/feeds`` route.
"""
conn, _ = get_connection()
rows = conn.execute("""
SELECT
f.id,
f.title,
f.url,
f.site_link,
f.first_seen,
f.last_fetched,
COUNT(h.id) AS headline_count
FROM feeds f
LEFT JOIN headlines h ON h.feed_id = f.id
GROUP BY f.id
ORDER BY f.title ASC
""").fetchall()
conn.close()
feed_list = [dict(r) for r in rows]
return render_template('feeds.html', feeds=feed_list, is_running=_is_running)
[docs]
@app.route('/runs')
def runs():
"""Render the run history page showing the last 50 aggregator runs.
Returns:
str: Rendered HTML response for the ``/runs`` route.
"""
conn, _ = get_connection()
rows = conn.execute("""
SELECT
id,
started_at,
finished_at,
elapsed_sec,
feeds_fetched,
feeds_failed,
articles_total,
articles_new
FROM runs
ORDER BY started_at DESC
LIMIT 50
""").fetchall()
conn.close()
run_list = [dict(r) for r in rows]
return render_template('runs.html', runs=run_list, is_running=_is_running)
[docs]
@app.route('/run', methods=['POST'])
def trigger_run():
"""Fire the aggregator in a background thread and return immediately.
Uses ``_run_lock`` to ensure only one run can be in progress at a time.
The frontend polls ``/status`` to detect completion. This route is
used by the Jinja2 frontend — the API equivalent lives at
``/api/v1/run``.
Returns:
tuple[flask.Response, int]: A JSON response with HTTP 202 in both
cases:
- ``{'status': 'started'}`` if the run was successfully launched.
- ``{'status': 'already_running'}`` if a run is already in progress.
"""
global _is_running
with _run_lock:
if _is_running:
return jsonify({'status': 'already_running'}), 202
_is_running = True
_is_running_ref[0] = True
t = threading.Thread(target=_run_aggregator, daemon=True)
t.start()
return jsonify({'status': 'started'}), 202
[docs]
@app.route('/status')
def status():
"""Return the current aggregator run state as JSON.
Polled every 3 seconds by the frontend JavaScript to update the
status indicator in the navigation bar. The API equivalent lives
at ``/api/v1/status``.
Returns:
tuple[flask.Response, int]: A JSON response with HTTP 200::
{'running': true} # aggregator is currently running
{'running': false} # aggregator is idle
"""
return jsonify({'running': _is_running})
if __name__ == '__main__':
app.run(debug=True, port=5000)