added count option and sorting for artists

This commit is contained in:
Ebbe Baß
2026-09-09 14:18:05 +02:00
parent 5d56df1af8
commit 594c101a07
6 changed files with 92 additions and 8 deletions
+18
View File
@@ -143,6 +143,18 @@ same filters except `upcoming` (it's always `false`).
| `date` | string | Exact match on the raw `event_date` string (`dd.mm.yy`) | | `date` | string | Exact match on the raw `event_date` string (`dd.mm.yy`) |
| `upcoming` | bool | `true` = event date is today or later, `false` = past events (events with an unparseable date are excluded) | | `upcoming` | bool | `true` = event date is today or later, `false` = past events (events with an unparseable date are excluded) |
| `sort` | string | `asc`/`desc` = chronological by `event_date` (events with an unparseable date always sort last, regardless of direction); `az`/`za` = alphabetical by `event_name` (case-insensitive) | | `sort` | string | `asc`/`desc` = chronological by `event_date` (events with an unparseable date always sort last, regardless of direction); `az`/`za` = alphabetical by `event_name` (case-insensitive) |
| `count` | int ≥ 1 | Limit the number of results returned, applied after filtering and sorting |
### `/artists` query parameters
| Param | Type | Meaning |
|-------|------|---------|
| `sort` | string | `az`/`za` = alphabetical by artist name (case-insensitive, default `az`); `asc`/`desc` = chronological by each artist's *latest* date (artists with no parseable date always sort last, regardless of direction) |
| `count` | int ≥ 1 | Limit the number of artists returned |
An artist's own `dates` list is always chronological (earliest first) —
`sort` only controls the order artists appear in, not the order of dates
within one artist.
### Response shapes ### Response shapes
@@ -250,6 +262,9 @@ curl 'https://fundi.api.example.com/events?date=05.09.26'
# Soonest events first # Soonest events first
curl 'https://fundi.api.example.com/events?sort=asc' curl 'https://fundi.api.example.com/events?sort=asc'
# Next 5 upcoming events
curl 'https://fundi.api.example.com/events?upcoming=true&sort=asc&count=5'
# Past events (archive) # Past events (archive)
curl https://fundi.api.example.com/events/archive curl https://fundi.api.example.com/events/archive
@@ -259,6 +274,9 @@ curl https://fundi.api.example.com/events/0
# All known artists # All known artists
curl https://fundi.api.example.com/artists curl https://fundi.api.example.com/artists
# 10 most recently active artists
curl 'https://fundi.api.example.com/artists?sort=desc&count=10'
# Calendar feed # Calendar feed
curl https://fundi.api.example.com/calendar.ics curl https://fundi.api.example.com/calendar.ics
+17 -2
View File
@@ -91,6 +91,7 @@ RELOAD_MIN_INTERVAL_SECONDS=30 uvicorn main:app
| `date` | string | Exact match on the raw date string (`dd.mm.yy`) | | `date` | string | Exact match on the raw date string (`dd.mm.yy`) |
| `upcoming` | bool | `true` = today or later, `false` = past events | | `upcoming` | bool | `true` = today or later, `false` = past events |
| `sort` | string | `asc`/`desc` = chronological by event date (unparseable dates sort last either way); `az`/`za` = alphabetical by event name (case-insensitive) | | `sort` | string | `asc`/`desc` = chronological by event date (unparseable dates sort last either way); `az`/`za` = alphabetical by event name (case-insensitive) |
| `count` | int ≥ 1 | Limit the number of results returned (applied after filtering and sorting) |
Examples: Examples:
@@ -99,13 +100,14 @@ curl 'http://127.0.0.1:8000/events?free=true'
curl 'http://127.0.0.1:8000/events?artist=randali&upcoming=true' curl 'http://127.0.0.1:8000/events?artist=randali&upcoming=true'
curl 'http://127.0.0.1:8000/events?sort=asc' curl 'http://127.0.0.1:8000/events?sort=asc'
curl 'http://127.0.0.1:8000/events?sort=za' curl 'http://127.0.0.1:8000/events?sort=za'
curl 'http://127.0.0.1:8000/events?sort=asc&count=5'
curl 'http://127.0.0.1:8000/events/0' curl 'http://127.0.0.1:8000/events/0'
``` ```
### `/artists` response shape ### `/artists`
Each artist appears once, with every date (past or upcoming) they're on the Each artist appears once, with every date (past or upcoming) they're on the
line-up for, sorted chronologically: line-up for:
```json ```json
[ [
@@ -113,3 +115,16 @@ line-up for, sorted chronologically:
{ "artist_name": "Skkin Velvet", "dates": ["04.09.26"] } { "artist_name": "Skkin Velvet", "dates": ["04.09.26"] }
] ]
``` ```
| Param | Type | Meaning |
|---------|---------|---------|
| `sort` | string | `az`/`za` = alphabetical by artist name (case-insensitive, default `az`); `asc`/`desc` = chronological by each artist's *latest* date (an artist has many dates, so this is the one used to place them) — artists with no parseable date sort last either way |
| `count` | int ≥ 1 | Limit the number of artists returned |
Each artist's own `dates` list is always chronological (earliest first),
regardless of `sort` — `sort` only controls the order artists appear in.
```bash
curl 'http://127.0.0.1:8000/artists?sort=desc' # most recently active first
curl 'http://127.0.0.1:8000/artists?sort=desc&count=10' # top 10 most recently active
```
Binary file not shown.
Binary file not shown.
+27
View File
@@ -133,6 +133,33 @@ def sort_events(events: List[Event], sort: Optional[str]) -> List[Event]:
return events return events
def sort_artist_schedules(items, sort: Optional[str]):
"""Sort (artist_name, dates) pairs per the ``?sort=`` query param.
"asc"/"desc" order by each artist's *latest* (most recent) date, since
an artist has many dates and not just one to sort on; artists with no
parseable date always sort last, regardless of direction. "az"/"za"
order alphabetically (case-insensitive) by name. None (or any other
value) defaults to "az", matching the plain-list behavior before sort
support existed.
"""
items = list(items)
if sort in ("asc", "desc"):
def _latest(dates) -> Optional[date]:
parsed = [d for d in (parse_event_date(x) for x in dates) if d is not None]
return max(parsed) if parsed else None
with_latest = [(name, dates, _latest(dates)) for name, dates in items]
dated = sorted(
(item for item in with_latest if item[2] is not None),
key=lambda item: item[2],
reverse=(sort == "desc"),
)
undated = [(name, dates) for name, dates, latest in with_latest if latest is None]
return [(name, dates) for name, dates, _ in dated] + undated
return sorted(items, key=lambda item: item[0].lower(), reverse=(sort == "za"))
def sort_date_strings(dates) -> List[str]: def sort_date_strings(dates) -> List[str]:
"""Sort raw ``dd.mm.yy`` date strings chronologically, de-duplicated. """Sort raw ``dd.mm.yy`` date strings chronologically, de-duplicated.
+30 -6
View File
@@ -16,15 +16,27 @@ from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import PlainTextResponse from fastapi.responses import PlainTextResponse
from calendar_feed import build_ics from calendar_feed import build_ics
from datasource import EventStore, iter_artist_names, parse_event_date, sort_date_strings, sort_events from datasource import (
EventStore,
iter_artist_names,
parse_event_date,
sort_artist_schedules,
sort_date_strings,
sort_events,
)
from models import ArtistSchedule, Event, EventList from models import ArtistSchedule, Event, EventList
from rate_limit import Cooldown from rate_limit import Cooldown
from scrape_schedule import discover_cron_expr, get_schedule_info from scrape_schedule import discover_cron_expr, get_schedule_info
SortOrder = Literal["asc", "desc", "az", "za"] SortOrder = Literal["asc", "desc", "az", "za"]
_SORT_DESCRIPTION = ( _EVENT_SORT_DESCRIPTION = (
"Sort order: asc/desc by event date, az/za alphabetically by event name" "Sort order: asc/desc by event date, az/za alphabetically by event name"
) )
_ARTIST_SORT_DESCRIPTION = (
"Sort order: asc/desc by each artist's latest date, "
"az/za alphabetically by artist name"
)
_COUNT_DESCRIPTION = "Limit the number of results returned"
app = FastAPI( app = FastAPI(
title="Fundi Scraper API", title="Fundi Scraper API",
@@ -124,7 +136,8 @@ def list_events(
artist: Optional[str] = Query(None, description="Case-insensitive substring match on any artist name"), artist: Optional[str] = Query(None, description="Case-insensitive substring match on any artist name"),
date: Optional[str] = Query(None, description="Exact match on the raw event date (dd.mm.yy)"), date: Optional[str] = Query(None, description="Exact match on the raw event date (dd.mm.yy)"),
upcoming: Optional[bool] = Query(None, description="true = today or later, false = past events"), upcoming: Optional[bool] = Query(None, description="true = today or later, false = past events"),
sort: Optional[SortOrder] = Query(None, description=_SORT_DESCRIPTION), sort: Optional[SortOrder] = Query(None, description=_EVENT_SORT_DESCRIPTION),
count: Optional[int] = Query(None, ge=1, description=_COUNT_DESCRIPTION),
): ):
today = datetime.date.today() today = datetime.date.today()
events = [ events = [
@@ -133,11 +146,16 @@ def list_events(
if _matches(e, free, name, artist, date, upcoming, today) if _matches(e, free, name, artist, date, upcoming, today)
] ]
events = sort_events(events, sort) events = sort_events(events, sort)
if count is not None:
events = events[:count]
return EventList(count=len(events), events=events) return EventList(count=len(events), events=events)
@app.get("/artists", response_model=List[ArtistSchedule]) @app.get("/artists", response_model=List[ArtistSchedule])
def list_artists(): def list_artists(
sort: Optional[SortOrder] = Query(None, description=_ARTIST_SORT_DESCRIPTION),
count: Optional[int] = Query(None, ge=1, description=_COUNT_DESCRIPTION),
):
"""Deduplicated, sorted list of all artists, each with every date """Deduplicated, sorted list of all artists, each with every date
(past or upcoming) they appear in the line-up. (past or upcoming) they appear in the line-up.
""" """
@@ -145,9 +163,12 @@ def list_artists():
for event in store.all(): for event in store.all():
for artist_name in iter_artist_names(event): for artist_name in iter_artist_names(event):
appearances[artist_name].add(event.event_date) appearances[artist_name].add(event.event_date)
ordered = sort_artist_schedules(appearances.items(), sort)
if count is not None:
ordered = ordered[:count]
return [ return [
ArtistSchedule(artist_name=artist_name, dates=sort_date_strings(dates)) ArtistSchedule(artist_name=artist_name, dates=sort_date_strings(dates))
for artist_name, dates in sorted(appearances.items(), key=lambda kv: kv[0].lower()) for artist_name, dates in ordered
] ]
@@ -157,7 +178,8 @@ def list_archive_events(
name: Optional[str] = Query(None, description="Case-insensitive substring match on event name"), name: Optional[str] = Query(None, description="Case-insensitive substring match on event name"),
artist: Optional[str] = Query(None, description="Case-insensitive substring match on any artist name"), artist: Optional[str] = Query(None, description="Case-insensitive substring match on any artist name"),
date: Optional[str] = Query(None, description="Exact match on the raw event date (dd.mm.yy)"), date: Optional[str] = Query(None, description="Exact match on the raw event date (dd.mm.yy)"),
sort: Optional[SortOrder] = Query(None, description=_SORT_DESCRIPTION), sort: Optional[SortOrder] = Query(None, description=_EVENT_SORT_DESCRIPTION),
count: Optional[int] = Query(None, ge=1, description=_COUNT_DESCRIPTION),
): ):
"""Past events only - shorthand for ``/events?upcoming=false``.""" """Past events only - shorthand for ``/events?upcoming=false``."""
today = datetime.date.today() today = datetime.date.today()
@@ -167,6 +189,8 @@ def list_archive_events(
if _matches(e, free, name, artist, date, upcoming=False, today=today) if _matches(e, free, name, artist, date, upcoming=False, today=today)
] ]
events = sort_events(events, sort) events = sort_events(events, sort)
if count is not None:
events = events[:count]
return EventList(count=len(events), events=events) return EventList(count=len(events), events=events)