From 594c101a075df94831b66645a344cfe75c588b11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ebbe=20Ba=C3=9F?= Date: Wed, 9 Sep 2026 14:18:05 +0200 Subject: [PATCH] added count option and sorting for artists --- README.md | 18 +++++++++++ api/README.md | 19 +++++++++-- api/__pycache__/datasource.cpython-313.pyc | Bin 9572 -> 11784 bytes api/__pycache__/main.cpython-313.pyc | Bin 9567 -> 10003 bytes api/datasource.py | 27 ++++++++++++++++ api/main.py | 36 +++++++++++++++++---- 6 files changed, 92 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2b898d5..99cc804 100644 --- a/README.md +++ b/README.md @@ -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`) | | `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) | +| `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 @@ -250,6 +262,9 @@ curl 'https://fundi.api.example.com/events?date=05.09.26' # Soonest events first 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) curl https://fundi.api.example.com/events/archive @@ -259,6 +274,9 @@ curl https://fundi.api.example.com/events/0 # All known 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 curl https://fundi.api.example.com/calendar.ics diff --git a/api/README.md b/api/README.md index be6bdbb..889cf8c 100644 --- a/api/README.md +++ b/api/README.md @@ -91,6 +91,7 @@ RELOAD_MIN_INTERVAL_SECONDS=30 uvicorn main:app | `date` | string | Exact match on the raw date string (`dd.mm.yy`) | | `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) | +| `count` | int ≥ 1 | Limit the number of results returned (applied after filtering and sorting) | 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?sort=asc' 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' ``` -### `/artists` response shape +### `/artists` Each artist appears once, with every date (past or upcoming) they're on the -line-up for, sorted chronologically: +line-up for: ```json [ @@ -113,3 +115,16 @@ line-up for, sorted chronologically: { "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 +``` diff --git a/api/__pycache__/datasource.cpython-313.pyc b/api/__pycache__/datasource.cpython-313.pyc index fc486b161561ef6228036f018e868cf28f685aa5..6c074d472a732bab7170997fb17abdbcfa603b40 100644 GIT binary patch delta 2292 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 Ku#bg0hyD$W>5HoX delta 2675 zcmbtVU2Gdg5Z=AB&yIhZ|2TG>#Btm-S4ct{+iglIqyhv^(}K4yp}t~_?b5V~6ZYIt zK;QsHP=Q2Y1wsX(B3{}D#6whx2ZSg>f+D0gpcZ6-Qo$21$R!aH;)R*>UqC4ju+q(b z`!lmMv-9nVJBTYmDW%OD_7;$`k%@B2&1fS+!bl(?GZP3 zM?BnP%-O~}B3|w_uzkEU(#5+X-MrgJGz)W>!3g&;C-2d0e!@1ede*=iM`iBUn*1bG zK7#jZ0YA~CXryv@t&dI^IA5Cq#F|)hNGeT*XsPF$G%pz0R2}!T7R}9CHLIqg8>|h` zW&^pZsJ)8Z)qNg~M)#L&)?^D9=+M+)1M_;w5fi^z^G7Gd8QS8hoX5I6wSWd-`Jnim zs`(+@ShG_>tVi=O|EQ!nAT1K>Wq~T*xCZyF!JF3L{uRI8{K>Smbp zVM>Ayb`!>RgjVn5mK!*Jv!-b6MjF9=Hn1k>;dM0d-q_&(iM1S`dHwjRLDj;m1!ZGH z`F}*UWgQ>U#apV^IJ_pJksIvZ0)>Uqk7&W5#DaK6HE*FuB{h_^LRCiZV*3`KHXWzb zEPgj{5w|N>7lq}DM9+ySTb^zczt|2m1mJYO0fcJfKIp_AdzX03ej8O6KeU@ov{jt1 z-$&bot>F%O_u_a%*hCM$|6B{DUh#bEXMut#oy=tlCViIYQVQr-ngf6g-^%Yqyy^&x zSDjXI)EkO=2+3OF_R6-&xyh!FN;CcCg*32hqMeYKA6H^K9kMqd=_So3>U1MR>g$4!Wlh&am&w)8z3^lgVo#MxKKP9ib2fAq- zPc~YNRw|{L+~b2}N|TbJayPo5fS^043s#+nZ+81Kb{!n#q1?zpTqckR4Eh ztR92x&h^GI3r34c@zyGqD+e%nRD$fojbZk6IV)!?SvK$?qMAh5GZ#ofu~i#Bi)eTS zuksBNgLfO*Du!*|e1NFZ9*#TsW)LbZNJr;&)b^FKY27iMoOvXj+*#-{@>Jo&1I52nN4MMgAsflS04wVn?Q)+j`qf<0lbVe zuYiDiwuu8BW1TkAtDf!nPTjtwZvR%@v9#;JH~#plS#~Ht`QzeJhx@MhZ#XIv*{uAr zLICQ>nG50WU2f&c`k_)8K^3G9Pt9jin72y9x;OZ=TIG|JHH%N$)V!ig<*Ri=Jt%9) z_3&gE+# zDMn3%I(O|d7-aD825UO=uu2pHqv$)eBniY`vw6nJn-|y4#>2n~;gc=qby8?ax zN~*T8km*9bG8S4~=vt=c6QKNoNc&cm7XWdec&Mkt{35`Q#WOv(Il=`?B9Wd=B@%o_ ze9?1yUqP9fO=qUdh0%mMSkeU>hsQ24JvB3xlZdT9nBIU{kkOH?Nv7~lS5a?p=C0%L`Hrj-OFU`nxpx0%fOOjaG~y+X?Ia3 zwCTE)ST-zL0zsO;cgYvI?yRRbOLI+Z(_E!uJ9z_rmOTjweoT#<=1Sd?%fKqT{vu4i2@dCuH List[Event]: 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]: """Sort raw ``dd.mm.yy`` date strings chronologically, de-duplicated. diff --git a/api/main.py b/api/main.py index cf685ee..89ff49b 100644 --- a/api/main.py +++ b/api/main.py @@ -16,15 +16,27 @@ from fastapi import FastAPI, HTTPException, Query from fastapi.responses import PlainTextResponse 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 rate_limit import Cooldown from scrape_schedule import discover_cron_expr, get_schedule_info SortOrder = Literal["asc", "desc", "az", "za"] -_SORT_DESCRIPTION = ( +_EVENT_SORT_DESCRIPTION = ( "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( 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"), 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"), - 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() events = [ @@ -133,11 +146,16 @@ def list_events( if _matches(e, free, name, artist, date, upcoming, today) ] events = sort_events(events, sort) + if count is not None: + events = events[:count] return EventList(count=len(events), events=events) @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 (past or upcoming) they appear in the line-up. """ @@ -145,9 +163,12 @@ def list_artists(): for event in store.all(): for artist_name in iter_artist_names(event): appearances[artist_name].add(event.event_date) + ordered = sort_artist_schedules(appearances.items(), sort) + if count is not None: + ordered = ordered[:count] return [ 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"), 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)"), - 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``.""" today = datetime.date.today() @@ -167,6 +189,8 @@ def list_archive_events( if _matches(e, free, name, artist, date, upcoming=False, today=today) ] events = sort_events(events, sort) + if count is not None: + events = events[:count] return EventList(count=len(events), events=events)