Saptashwa
Save-for-later system with a Raspberry Pi pollerNov 2023 – Mar 2024

Recall

Save-it-for-later system: apps share links, a Raspberry Pi poller enriches them, everything syncs.

My partWrote all of it by hand, before coding agents: the apps, the Supabase schema and the Pi poller.
Hand-built
Recall's saved-items list on Android with source filters for YouTube, Threads, Insta and Article
Saved items with source filters. The items shown are made up.
The Save Item dialog with a URL field and folder tags
Saving a link to folders, on Windows.
A list of folder tags
Folder tags.
20 monthsof a hand-written poller running every 10 minutes on a Raspberry Pi, 98% of runs clean

What it does

  • Share a link from any app on Android or Windows, and it lands in Supabase.
  • A Node.js poller on a Raspberry Pi picks it up every 10 minutes, fetches the title, channel, thumbnail and article text from the YouTube API and web sources, and writes it back.
  • Universal search, folder tags, and articles saved as Markdown to read offline.
  • Multi-device sync, with a Windows app at feature parity with the phone.
  • The poller ran 82,791 times between November 2023 and July 2025, until the Supabase project was paused.

A hand-written excerpt from the poller

Trimmed from processAndSaveToSupabase.js (January 2024). Lines with keys, the per-source parsing and logging are left out; the comments marked trimmed are mine, everything else is as written.

async function main() {
    console.log('START:: ' + new Date(Date.now()).toISOString());
    const t0 = performance.now(); 
    const { data, error } = await supabase
        .from('share_data')
        .select()
        .eq('is_processed', false);
    // …trimmed

    for(let i = 0; i < data.length; i++) {
        let shareItem = data[i];

        let shareUrl = shareItem.share_url;
        let ytVideoID = extractYouTubeVideoId(shareUrl);

        if(ytVideoID != null) {
            // …trimmed: skip videos already in youtube_data, otherwise queue the ID
        } else {
            let htmlContent = await fetchHTML(shareUrl);
            // …trimmed: parse the page and save the article as Markdown
        }
    }

    if(ytVideoIDList.length > 0) {
        let videoIDLists = extractYTVideoLists(ytVideoIDList);
        console.log(videoIDLists.length);
        for(i2 = 0; i2 < videoIDLists.length; i2++) {
            await getVideoInfo(ytVideoIDList.join(','), shareIDList, ytVideoIDList, shareURLList);
        }
    }
    // …trimmed
}

function extractYTVideoLists(ytVideoIDList) {
    let maxVideoIDs = 49;
    let resultArray = [];

    for(let i1 = 0; i1 < ytVideoIDList.length; i1+=maxVideoIDs) {
        resultArray.push(ytVideoIDList.slice(i1, i1 + maxVideoIDs));
    }

    return resultArray;
}

Looking back. The batching loop passes the whole list of IDs to every call instead of each batch of 49. It never broke, because saves came in ones and twos, but 120 links shared at once would have failed. Today I’d pass videoIDLists[i2] and add a test that saves 120 links in one go.

Shares a stack with