From 5d56df1af8dc35991a3d5481659bd995f00187b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ebbe=20Ba=C3=9F?= Date: Wed, 9 Sep 2026 14:10:15 +0200 Subject: [PATCH] added sorting and artist dates --- README.md | 16 +++++++- api/README.md | 19 +++++++++- api/__pycache__/calendar_feed.cpython-313.pyc | Bin 0 -> 7059 bytes api/__pycache__/datasource.cpython-313.pyc | Bin 4592 -> 9572 bytes api/__pycache__/main.cpython-313.pyc | Bin 5149 -> 9567 bytes api/__pycache__/models.cpython-313.pyc | Bin 1304 -> 1566 bytes api/__pycache__/rate_limit.cpython-313.pyc | Bin 0 -> 1962 bytes .../scrape_schedule.cpython-313.pyc | Bin 5559 -> 5849 bytes api/datasource.py | 35 ++++++++++++++++++ api/main.py | 32 ++++++++++++---- api/models.py | 5 +++ 11 files changed, 96 insertions(+), 11 deletions(-) create mode 100644 api/__pycache__/calendar_feed.cpython-313.pyc create mode 100644 api/__pycache__/rate_limit.cpython-313.pyc diff --git a/README.md b/README.md index fcc01d4..2b898d5 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ DATA_FILE=/path/to/events.json ARCHIVE_DATA_FILE=/path/to/archive.json uvicorn m | `GET` | `/events` | List events, with optional filters (below) | | `GET` | `/events/archive` | Past events only — shorthand for `/events?upcoming=false` | | `GET` | `/events/{index}` | A single event by its 0-based position in the merged list; `404` if out of range | -| `GET` | `/artists` | Deduplicated, case-insensitively sorted list of all artist names | +| `GET` | `/artists` | Deduplicated, case-insensitively sorted list of all artists, each with every date they appear on | | `GET` | `/calendar.ics` | iCalendar/webcal feed of all events — subscribe from any calendar app | | `POST` | `/reload` | Re-read the data file(s) from disk (call after re-running the scraper); rate-limited (below); `500` if the main file is missing | @@ -142,6 +142,7 @@ same filters except `upcoming` (it's always `false`). | `artist` | string | Case-insensitive substring match on any artist name in the line-up | | `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) | ### Response shapes @@ -155,7 +156,15 @@ same filters except `upcoming` (it's always `false`). ``` `GET /events/{index}` returns a single event object (same schema as the scraper -output). `GET /artists` returns a plain JSON array of strings. +output). `GET /artists` returns each artist once with every date (past or +upcoming) they're in the line-up for, sorted chronologically: + +```json +[ + { "artist_name": "Bizarre", "dates": ["05.09.26", "12.10.26"] }, + { "artist_name": "Skkin Velvet", "dates": ["04.09.26"] } +] +``` ### Scrape schedule reporting @@ -238,6 +247,9 @@ curl 'https://fundi.api.example.com/events?artist=randali&upcoming=true' # Everything on a given night curl 'https://fundi.api.example.com/events?date=05.09.26' +# Soonest events first +curl 'https://fundi.api.example.com/events?sort=asc' + # Past events (archive) curl https://fundi.api.example.com/events/archive diff --git a/api/README.md b/api/README.md index a4a2698..be6bdbb 100644 --- a/api/README.md +++ b/api/README.md @@ -75,12 +75,14 @@ RELOAD_MIN_INTERVAL_SECONDS=30 uvicorn main:app | GET | `/events` | List events, with optional filters (see below) | | GET | `/events/archive` | Past events only — shorthand for `/events?upcoming=false` | | GET | `/events/{index}` | Single event by its position in the merged list (0-based) | -| GET | `/artists` | Deduplicated, sorted list of all artist names | +| GET | `/artists` | Deduplicated, sorted list of all artists with every date they appear on | | GET | `/calendar.ics` | iCalendar/webcal feed of all events — subscribe with `webcal:///calendar.ics` | | POST | `/reload` | Re-read the data file(s) from disk (after a fresh scrape) - rate-limited, see below | ### `/events` query parameters +`/events/archive` accepts the same parameters except `upcoming` (always `false`). + | Param | Type | Meaning | |------------|--------|---------| | `free` | bool | Only free / only paid events | @@ -88,11 +90,26 @@ RELOAD_MIN_INTERVAL_SECONDS=30 uvicorn main:app | `artist` | string | Case-insensitive substring match on any artist name | | `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) | Examples: ```bash 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/0' ``` + +### `/artists` response shape + +Each artist appears once, with every date (past or upcoming) they're on the +line-up for, sorted chronologically: + +```json +[ + { "artist_name": "Bizarre", "dates": ["05.09.26", "12.10.26"] }, + { "artist_name": "Skkin Velvet", "dates": ["04.09.26"] } +] +``` diff --git a/api/__pycache__/calendar_feed.cpython-313.pyc b/api/__pycache__/calendar_feed.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..42ed6ac97ad2ecfa48073f58d942975e40028f3d GIT binary patch literal 7059 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 A!T)_QQ^TE$4RF+o$+rxchiD5lXK7c_Wpe5 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 EU#7;9;{X5v delta 2538 zcmah~O>7&-6`t8Y{!^4lilQi5D_OS0BqFU^a;+wE>^O27xmFBq2q-24dQGk@+7!vo zE{)nKK)PsA3qI7s!bysxs1vlP0~AHmv}l0>0dgza9wazTg+&0nMUY%_pknt>^w7Sy zYf(y5qXX=lH#6VPy#3~TZ|~0iXe@X?5bzRcQ_HVi_`QEE*q1oO5pt6#gb{@*+)2tP z^!!O~j%RDL(`VUg0Jt~*Z`#!Q(;n)#l|kP_}E7X%i~hZe@|eZOFo$P{%ZlxY9X(J??zd;w?= zl->?HwhJA9#NN%me3ufR=JIa%N>1rl5*>eFm!I6_@7d)KqCYAA9|q(vGcf5@hVY}C z7ifpf{zt?(oaY~s3V|wncYRgj5pRsZr66`+eqfSQ_TxdFQ+Z` zt}#8Moja#xj@L@%>iNu4qgpDD>qVy4H8!r+%NcykUwJb0G4t=9r_c7oMOj`Aw=PuG z70u$+>SgAESr$QqDouG=2>os}J!ryc?DZ&myd3z=+nlm##Go0Q0i!BNopt_AE|jMf0<0yeXaLP7M&^BQmMF02SCq<+`@LtZSS2tV^NOaA`Ux z!&8-(;8W*Q9D5R=mSiV@o^o66l4@v1c|~JKV8K?lO&-hO5S7a?N(Qy@3`x2FJ16Xy zc5;Gh>?ojhHyHV0a|G(EK!O*XW7%<7ehzUZFfH;RWd6H%F5{2BaqzWEuRnSF(uVKQ zx^U>9pZkQMq$3xKQb8my=>)R|Mfv2I(^3KWKl&QLP8Y@xc>>rSx3xmvA)95#s~bz9q~5Q#<8 z66!VGaB?2yX>(`s<*H#v&c1?k7`Y>7_fv3GqoN&Qi!g`s)Bgyz%~MLZI2RS}_YfMo zONKrr6B}gWQ*vU1oVZK&{!I$RH@(TtP;S#7-f{^puElTpi6?yB-xAG#4PQ=E`ZB%G z6E4!`&~3X3Cw{;0YTvcg9U9rw&o#Gv0s7TVZ_ka%)pVN&{~q`U zSI65T_)wq%(HqL@skRG8ZsH5uYaSeVNmuNKv08nAJK(v9vZtEyVif}+1uea dpKrz1X?lwj=@^1CZS>lqhamH7`-TLh=077f6g2<< diff --git a/api/__pycache__/models.cpython-313.pyc b/api/__pycache__/models.cpython-313.pyc index 9198c7ae958715959d3118f962d9f9a0b1573bcc..23a11cff0501d26b1c038d77b2b0647e711eb113 100644 GIT binary patch delta 319 zcmbQiHIIkyGcPX}0}wp%UYMo8G?7n&v0|cnvXlga5<@Vj6hkmq42vl+6p4TcQ4j$#9ZY~sDh8=- zXSg9Ka+yWs1~>l%=1$9NEFuu)AeDZaoRfQ53>noY?_!bV(gBJvg0xvre#oLL_Jx6o TQQ`|16Qk&a$ge=QC|Dl=Q%gtQ delta 142 zcmbQoGlProGcPX}0}wnJU}ChL_mZnkhsNR glbfGXnv-f*qygkI0&%ha7%Q6rNqL9edOK(WGi=)J&ryY8tyeP^m-|DGCzMA_v7ps#2?)@$T5(WEU$Zuxed-LYK@BL(N zWW*)#egE#a*MD&c`4bQQ56;QAcLA1dQYMO&t+IXDQdY9s%7!&{Id#cV4kbs)EGauL zlXCjJ-My&NbvvJ77c4>+XfqUHlSR~*G798|pmk-9fX5k_@mUnjCSlFdjYjAyli<$P6mDI5C&^BO0p1aKuM?0k zLd2G?twD;oy2&E12h7^qTpz>gW)d|jbES*%_(5I2S09Pe}bmo=m-R*V%GAXU^VrJ{{Ww zIf*lho3;`@^wZ$Wz%GIBAJ}Y@s{_IE0K5U~Imm>yLS6)_O(+1kj8+eP)j5Ka>;X0$ zuZ+J)zW{>;f5Kj|%l7=2gs@?-U~3#2@Yf#_l$^q(|DV7E(zYc) zeo|7mE~aKk&B~{4Jzg+|H(LfL3brT3w!{$wQ)4-SP16!KdRXgLH1zLjCIYIUu@yA7 zX+XCo1Z>e+D%HLNc&dMuxK}{&<3(LmJaeN2$CFXq!hMgoDy;)&MDr6(F9gBqDCoI zV<;RpYSUx>jdrMbJUJ+|=eS2eO>-c3$g@#0cKE*dSUed$aW~Z&nYh3D$!Z5!x9{EF z8GEfWO}{QZEIsmnTKaKmr?|K?{q|?M&f)0?H@>*oIeuzCM@Em{TitcZ%xFCF$AGtMBUgh%2M9N1`({up+Q{FYs#P< z;r@{4-DtC@CmHj+TBx)E__&ZD9`d|E`mjmYU}PFKj%E=Nbqq*6<#{k+P3U{f7!_9A zhHKCJfvA^I1rbv?3loCi>XSe|BELEx?mDUTxkppa2rRqqXnOL&m2db%Z=b-ndmAt2 zGinC!<3CU}1tiXx%@$;(>qBCD7G;C?;_RE<*xpj7KmesoG@95=S(f#eow1y~abivU TMuz_&$M-WwtjWI#ki@`$$lURZ literal 0 HcmV?d00001 diff --git a/api/__pycache__/scrape_schedule.cpython-313.pyc b/api/__pycache__/scrape_schedule.cpython-313.pyc index fac45a5624cd4c17c4970cafe6178dfbce04c780..3768ea5a46b0cb092805715ae14c525df3e8dd1b 100644 GIT binary patch delta 604 zcmYjNL2DC16rN3XBi(G$grun`>LZkh!&U}G4 zz@t71paj^5e4UdG#zY>NPgKGIYzo>TPN-}Dm1*D?1YqFA?r~22EoI{pr{@t^26H!#J=_5UyJL*xjRY z%350fC)92oERI>6d~#?dOiioE*<{{O|BPD0m1JLw z)fMFpxj$VtRu#QB{54&T#=-`^D$AL|OeLiC5Udc^LbxTjGxNr}qR#MnCZ*-VZTR0u viXG|>wFO_7``I(vT{+6SXLDNWWpj*Fc;g(054)eEA0Hisnejz=f2REhM<=Jp delta 338 zcmcbqyIq^_GcPX}0}#|}EX)!W*vPk)i>ZuZ@0g0Ex-Pyn%w|K!yzv7yAK;28J(uldtls zOy=g3WfNu)RQn*ZS&xs4k(8-4 List[Event]: + """Sort events per the ``?sort=`` query param. + + "asc"/"desc" order chronologically by ``event_date``; events with an + unparseable date always sort last, regardless of direction. "az"/"za" + order alphabetically (case-insensitive) by ``event_name``. Any other + value (including None) leaves the events in their existing order. + """ + if sort in ("asc", "desc"): + with_parsed = [(e, parse_event_date(e.event_date)) for e in events] + dated = sorted( + (item for item in with_parsed if item[1] is not None), + key=lambda item: item[1], + reverse=(sort == "desc"), + ) + undated = [e for e, parsed in with_parsed if parsed is None] + return [e for e, _ in dated] + undated + if sort in ("az", "za"): + return sorted(events, key=lambda e: e.event_name.lower(), reverse=(sort == "za")) + return events + + +def sort_date_strings(dates) -> List[str]: + """Sort raw ``dd.mm.yy`` date strings chronologically, de-duplicated. + + Unparseable strings sort last (alphabetically among themselves). + """ + unique = set(dates) + dated = sorted( + (d for d in unique if parse_event_date(d) is not None), key=parse_event_date + ) + undated = sorted(d for d in unique if parse_event_date(d) is None) + return dated + undated diff --git a/api/main.py b/api/main.py index 9bc6b84..cf685ee 100644 --- a/api/main.py +++ b/api/main.py @@ -9,17 +9,23 @@ Run with: import datetime import os -from typing import List, Optional +from collections import defaultdict +from typing import List, Literal, Optional 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 -from models import Event, EventList +from datasource import EventStore, iter_artist_names, parse_event_date, 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 = ( + "Sort order: asc/desc by event date, az/za alphabetically by event name" +) + app = FastAPI( title="Fundi Scraper API", description="REST API over the scraped upcoming/past events at the Fundbureau Hamburg.", @@ -118,6 +124,7 @@ 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), ): today = datetime.date.today() events = [ @@ -125,16 +132,23 @@ def list_events( for e in store.all() if _matches(e, free, name, artist, date, upcoming, today) ] + events = sort_events(events, sort) return EventList(count=len(events), events=events) -@app.get("/artists", response_model=List[str]) +@app.get("/artists", response_model=List[ArtistSchedule]) def list_artists(): - """Deduplicated, sorted list of all artist names across all events.""" - names = set() + """Deduplicated, sorted list of all artists, each with every date + (past or upcoming) they appear in the line-up. + """ + appearances: dict = defaultdict(set) for event in store.all(): - names.update(iter_artist_names(event)) - return sorted(names, key=str.lower) + for artist_name in iter_artist_names(event): + appearances[artist_name].add(event.event_date) + 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()) + ] @app.get("/events/archive", response_model=EventList) @@ -143,6 +157,7 @@ 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), ): """Past events only - shorthand for ``/events?upcoming=false``.""" today = datetime.date.today() @@ -151,6 +166,7 @@ def list_archive_events( for e in store.all() if _matches(e, free, name, artist, date, upcoming=False, today=today) ] + events = sort_events(events, sort) return EventList(count=len(events), events=events) diff --git a/api/models.py b/api/models.py index 8dbabd8..e0f0561 100644 --- a/api/models.py +++ b/api/models.py @@ -21,3 +21,8 @@ class Event(BaseModel): class EventList(BaseModel): count: int events: List[Event] + + +class ArtistSchedule(BaseModel): + artist_name: str + dates: List[str]