Pipeline for one user:
- Authenticate to Spotify with a short lived token from
.env. - Fetch that user’s top tracks.
- Heuristically select tracks in a target language.
- For those tracks, fetch lyrics from Genius and scrape text.
- (Planned) Cache lyrics and metadata in a database and later derive vocab flashcards.
Right now you have a working end to end pipeline up to step 4, entirely in scripts and pure Python modules.
Top level:
-
pyproject.toml- Defines the project as an installable package with
srclayout. - Declares dependencies:
requests,python-dotenv,langdetect,beautifulsoup4and others. - Critical for imports to work (
pip install -e .).
- Defines the project as an installable package with
-
.envand.env.example-
.env.exampledocuments required environment variables. -
.env(ignored from VCS) has real values. -
Currently used vars:
SPOTIFY_ACCESS_TOKENGENIUS_ACCESS_TOKEN- (Planned)
DATABASE_URL
-
-
scripts/- Contains thin command line entrypoints for manual runs and experiments.
- Must not contain real business logic.
-
src/spotify_vocab/- All reusable code lives here.
- This is what will later back your FastAPI app and any background jobs.
-
tests/- Placeholder for unit tests.
Responsibility:
- Central place for configuration and environment variable access.
- Defines typed config objects and returns them via functions.
Current elements:
-
SpotifyConfigandget_spotify_config()- Reads
SPOTIFY_ACCESS_TOKEN. - Holds defaults for
time_rangeandlimit.
- Reads
-
GeniusConfigandget_genius_config()- Reads
GENIUS_ACCESS_TOKEN. - Holds base URL for Genius API.
- Reads
-
(Planned)
DatabaseConfigandget_database_config()- Will read
DATABASE_URL. - Used by SQLAlchemy setup.
- Will read
Key point: all secrets and environment setup pass through this module, nowhere else.
Responsibility:
- Internal, API independent representations of your domain objects.
Current elements:
-
Artistdataclassid,name.
-
Trackdataclassid(Spotify track id).name.artists: list[Artist].uri,href,preview_url.display_nameproperty that formats"{track} by {artists}".
You never pass raw Spotify JSON around. You convert it once in spotify_client and then work with these models.
Responsibility:
- All interaction with the Spotify Web API.
- No project logic, only API calls and mapping to internal models.
Key parts:
-
SpotifyApiErrorexception. -
SpotifyClientclass-
Initialized with a
SpotifyConfig. -
Internal
_get()that:- Builds URL
https://api.spotify.com/v1/.... - Adds authorization headers.
- Handles errors and returns parsed JSON.
- Builds URL
-
get_current_user_top_tracks(limit, time_range):- Calls
/me/top/trackswith params. - Parses items into
Trackinstances via_parse_track.
- Calls
-
The rest of the app never calls Spotify directly, only SpotifyClient.
Responsibility:
- Heuristic language detection for tracks based on text metadata.
Functions:
-
detect_track_language(track: Track) -> str | None- Concatenates track name and artist names.
- Uses
langdetect.detect. - Returns ISO 639 language code or
None.
-
filter_tracks_by_language(tracks, target_language)- Filters a sequence of
Trackobjects by language code.
- Filters a sequence of
Important caveat:
- This is only a prefilter and unreliable for short or ambiguous text.
- Proper language detection must be done on lyrics once you have them.
Responsibility:
- Encapsulate the logic for “get candidate tracks in a target language”.
Function:
-
get_candidate_tracks_for_language(client, target_language, limit, time_range)- Calls
SpotifyClient.get_current_user_top_tracks. - Applies
filter_tracks_by_language. - Returns a list of candidate
Trackobjects.
- Calls
This is your current “pool of songs for language X” abstraction.
Responsibility:
- Common interface for any lyrics provider.
Elements:
-
LyricsProviderError- Base exception for provider related failures.
-
LyricsProviderabstract base class-
nameproperty- Short identifier of the provider, for example
"genius".
- Short identifier of the provider, for example
-
get_lyrics_for_track(track: Track) -> Optional[str]- Returns full lyrics text or
None.
- Returns full lyrics text or
-
Strict rule: every external lyrics service must be wrapped in a class implementing this interface. No raw HTTP in the rest of the code.
Responsibility:
- Concrete implementation of
LyricsProviderusing Genius API plus HTML scraping.
Workflow inside GeniusProvider:
-
Build search query from Spotify track:
"{track.name} {primary_artist.name}".
-
Call Genius search API:
GET https://api.genius.com/search?q=<query>.- Authorization header from
GeniusConfig. - Extract
hitslist from JSON.
-
Select the best hit:
- First try exact match on title and primary artist name (case insensitive).
- Then fallback to exact title only.
- Then fallback to first hit.
-
Derive song page URL:
- Use
result["url"], or fallback tohttps://genius.com{path}if onlypathis present.
- Use
-
Scrape lyrics from song page:
- Fetch HTML.
- Remove
<script>tags. - Extract all
divelements withdata-lyrics-container="true"and join their text. - If not present, fallback to legacy
div class="lyrics". - Return combined text or
None.
Genius API and HTML details are completely isolated in this module.
Responsibility:
- High level function to “get lyrics for a batch of tracks” using any provider.
Current simple version:
-
fetch_lyrics_for_tracks(provider, tracks)-
For each track:
- Calls
provider.get_lyrics_for_track(track). - If non empty, adds
(track, lyrics)to results list.
- Calls
-
Returns
list[(Track, str)].
-
This function knows nothing about Genius or Spotify specifics.
After we add caching and a database, this module will gain session handling and cache lookups, but structure stays the same.
Responsibility:
- Thin CLI layer to exercise the pipeline.
What it does:
-
Parse arguments:
--lang(required): language code likeru,en.--limit: number of top tracks (default 50).--time-range:short_term,medium_term,long_term.--print-lyrics: flag to also fetch and print lyrics.
-
Load config and construct clients:
SpotifyConfigandSpotifyClient.- For lyrics phase,
GeniusConfigandGeniusProvider.
-
Fetch and display tracks:
- Calls
get_candidate_tracks_for_language. - Prints count and each track’s
display_name.
- Calls
-
Optionally fetch lyrics:
- Calls
fetch_lyrics_for_tracks(provider, candidates). - Prints count and for each
(track, lyrics)a short multi line preview.
- Calls
The script itself contains no scraping logic, no direct API calls. It just wires modules together.
End to end flow for one run with --print-lyrics:
-
scripts/fetch_top_tracks.py→get_spotify_config()→SpotifyClient→get_candidate_tracks_for_language()→SpotifyClient.get_current_user_top_tracks()→language_filter.filter_tracks_by_language()→ list ofTrackcandidates. -
If
--print-lyricsis provided →get_genius_config()→GeniusProvider→fetch_lyrics_for_tracks(provider, candidates)→ for each track: →GeniusProvider.get_lyrics_for_track(track)→ Genius search API → Genius page scrape → return lyrics string. -
Script prints debug counts and simple textual representation.
Nothing is persisted yet. Each run hits both Spotify and Genius fresh.
This is the next coherent block of work, in order.
Goal: avoid re scraping the same track lyrics every time and prepare for real backend use.
Planned pieces:
-
db.py- SQLAlchemy engine creation based on
DatabaseConfig. Basedeclarative base.init_db()to create tables.get_session()context manager for transactions.
- SQLAlchemy engine creation based on
-
db_models.py-
LyricsCacheORM model:id: primary key.spotify_track_id.provider.lyrics_text.language(optional).created_at.- Unique constraint on
(spotify_track_id, provider).
-
-
lyrics_repository.pyget_cached_lyrics(session, spotify_track_id, provider) -> Optional[str].store_lyrics(session, spotify_track_id, provider, lyrics_text, language=None).
-
lyrics_fetcher.pywill change to:-
Take a
SessionandLyricsProvider. -
For each track:
- Check cache.
- If cached, use it.
- If not, call provider and then store in cache.
-
CLI script will call init_db() once and use get_session() around the fetch_lyrics_for_tracks call.
After cache exists:
-
Introduce a module, for example
lyrics_language.py, to detect language from full lyrics text. -
On first insert into cache, detect and store
languageinLyricsCache. -
Introduce a more accurate selection function:
- Use cached lyrics and their language to find tracks that actually match the user’s target language.
- Move away from relying solely on track titles.
Once lyrics are available and language for each is known:
-
New module, for example
text_processing.py:- Tokenization and POS tagging (likely via spaCy).
- Stopword removal.
- Scoring of candidate words (frequency in song vs base frequency in language).
- Extraction of example sentences or lines containing the word.
Outputs will be internal models such as CardCandidate that later get written to a dedicated table.
Another provider abstraction:
translation_provider.pywithTranslationProviderinterface.- Implementations using Google Translate API or similar.
- Use this to fill in
word_translationandsentence_translationwhen generating cards.
After data model and processing logic are stable:
-
Add a FastAPI application module, for example
api.py. -
Expose endpoints:
/tracks/lyrics/cards
-
Reuse the same domain modules and repository layer.
Mental map of layers:
-
Edge layer
- CLI scripts today.
- Later HTTP endpoints.
-
Application layer (orchestration)
track_selectionfor selecting tracks.lyrics_fetcherfor getting lyrics in bulk.- Future card generation services.
-
Domain layer (pure logic, no IO)
models, language detection, text processing, scoring, selection rules.
-
Infrastructure layer
spotify_client,lyrics_provider_genius, future translation providers.- Database modules:
db,db_models,lyrics_repository. - Configuration:
config.
This project is licensed under the MIT License.
See the LICENSE file for details.