Login
1 branch 0 tags
Ben (Desktop/Arch) AGENTS.md b60dcff 29 days ago 82 Commits
moon / src / podcast_sync.c
#include "podcast_sync.h"
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "moon.h"
#include "network.h"
#include "podcast_xml.h"
#include "storage.h"
#include "ui.h"

#define PODCASTS_ROOT "/Podcasts"
#define PODCASTS_OPML "/Podcasts/podcasts.xml"
#define MAX_DIRNAME 64

typedef enum {
	SYNC_INIT,
	SYNC_ENABLE_NETWORK,
	SYNC_DOWNLOAD_FEED,
	SYNC_PARSE_FEED,
	SYNC_DOWNLOAD_EPISODE,
	SYNC_NEXT_FEED,
	SYNC_DONE,
} sync_phase_t;

static podcast_sync_status_t status;
static sync_phase_t phase;

static podcast_feed_t* feeds;
static int feed_count;

static podcast_episode_t* episodes;
static int episode_count;

// Keep feed_dir short enough that appending /<ep_dirname>/<audio_name>
// still fits in STORAGE_MAX_PATH. /Podcasts/ = 10, dirname ≤ 64 → 74 max.
#define FEED_DIR_MAX 80
static char feed_dir[FEED_DIR_MAX];

// Sanitize string for use as directory name
static void sanitize_dirname(const char* src, char* dst, size_t dst_size) {
	size_t len = 0;
	size_t max = dst_size - 1;
	if (max > MAX_DIRNAME) {
		max = MAX_DIRNAME;
	}

	for (const char* p = src; *p && len < max; p++) {
		char c = *p;
		if (isalnum((unsigned char)c) || c == '.' || c == '_' || c == '-') {
			dst[len++] = c;
		} else if (len > 0 && dst[len - 1] != '_') {
			dst[len++] = '_';
		}
	}

	// Trim trailing underscore
	while (len > 0 && dst[len - 1] == '_') {
		len--;
	}

	if (len == 0) {
		dst[0] = '_';
		len = 1;
	}
	dst[len] = '\0';
}

void podcast_sync_start(void) {
	memset(&status, 0, sizeof(status));
	phase = SYNC_INIT;

	feeds = NULL;
	episodes = NULL;
	feed_count = 0;
	episode_count = 0;
}

void podcast_sync_start_single(const char* title, const char* url) {
	memset(&status, 0, sizeof(status));
	phase = SYNC_ENABLE_NETWORK;

	episodes = NULL;
	episode_count = 0;

	feeds = malloc(sizeof(podcast_feed_t));
	if (!feeds) {
		status.current_action = "Out of memory";
		status.done = true;
		return;
	}

	strncpy(feeds[0].title, title, sizeof(feeds[0].title) - 1);
	feeds[0].title[sizeof(feeds[0].title) - 1] = '\0';
	strncpy(feeds[0].url, url, sizeof(feeds[0].url) - 1);
	feeds[0].url[sizeof(feeds[0].url) - 1] = '\0';
	feed_count = 1;

	status.total_feeds = 1;
	status.current_feed = 0;

	storage_mkdir("Podcasts");
}

bool podcast_sync_step(void) {
	if (status.cancelled || status.done) {
		return false;
	}

	switch (phase) {
		case SYNC_INIT: {
			status.current_action = "Creating directories";
			storage_mkdir("Podcasts");

			feeds = malloc(PODCAST_MAX_FEEDS * sizeof(podcast_feed_t));
			if (!feeds) {
				status.current_action = "Out of memory";
				status.done = true;
				return false;
			}

			feed_count =
			    podcast_parse_opml(PODCASTS_OPML, feeds, PODCAST_MAX_FEEDS);
			if (feed_count <= 0) {
				status.current_action = "No feeds in podcasts.xml";
				status.done = true;
				free(feeds);
				feeds = NULL;
				return false;
			}

			status.total_feeds = feed_count;
			status.current_feed = 0;
			phase = SYNC_ENABLE_NETWORK;
			return true;
		}

		case SYNC_ENABLE_NETWORK: {
			status.current_action = "Connecting to network";
			if (!network_set_enabled(true)) {
				status.current_action = "Network connection failed";
				snprintf(status.last_error, sizeof(status.last_error),
				         "Could not connect to WiFi");
				status.errors++;
				status.done = true;
				free(feeds);
				feeds = NULL;
				return false;
			}
			phase = SYNC_DOWNLOAD_FEED;
			return true;
		}

		case SYNC_DOWNLOAD_FEED: {
			if (status.current_feed >= feed_count) {
				phase = SYNC_DONE;
				return true;
			}

			podcast_feed_t* feed = &feeds[status.current_feed];
			status.current_action = feed->title;

			// Create feed directory
			char dirname[MAX_DIRNAME + 1];
			sanitize_dirname(feed->title, dirname, sizeof(dirname));
			snprintf(feed_dir, sizeof(feed_dir), "%s/%s", PODCASTS_ROOT,
			         dirname);
			storage_mkdir(feed_dir);

			// Download feed XML
			char feed_path[STORAGE_MAX_PATH];
			snprintf(feed_path, sizeof(feed_path), "%s/feed.xml", feed_dir);

			if (!network_download_file(feed->url, feed_path)) {
				ESP_LOGI("SYNC", "Failed to download feed: %s\n", feed->title);
				snprintf(status.last_error, sizeof(status.last_error),
				         "Failed to download: %.100s", feed->title);
				status.errors++;
				phase = SYNC_NEXT_FEED;
				return true;
			}

			phase = SYNC_PARSE_FEED;
			return true;
		}

		case SYNC_PARSE_FEED: {
			char feed_path[STORAGE_MAX_PATH];
			snprintf(feed_path, sizeof(feed_path), "%s/feed.xml", feed_dir);

			if (episodes) {
				free(episodes);
			}
			episodes = malloc(PODCAST_MAX_EPISODES * sizeof(podcast_episode_t));
			if (!episodes) {
				snprintf(status.last_error, sizeof(status.last_error),
				         "Out of memory parsing feed");
				status.errors++;
				phase = SYNC_NEXT_FEED;
				return true;
			}

			episode_count =
			    podcast_parse_rss(feed_path, episodes, PODCAST_MAX_EPISODES);
			if (episode_count <= 0) {
				ESP_LOGI("SYNC", "No episodes in feed\n");
				phase = SYNC_NEXT_FEED;
				return true;
			}

			status.total_episodes = episode_count;
			status.current_episode = 0;
			phase = SYNC_DOWNLOAD_EPISODE;
			return true;
		}

		case SYNC_DOWNLOAD_EPISODE: {
			if (status.current_episode >= episode_count) {
				phase = SYNC_NEXT_FEED;
				return true;
			}

			podcast_episode_t* ep = &episodes[status.current_episode];
			status.current_action = ep->title;

			// Create episode directory
			char ep_dirname[MAX_DIRNAME + 1];
			sanitize_dirname(ep->guid[0] ? ep->guid : ep->title, ep_dirname,
			                 sizeof(ep_dirname));

			// feed_dir(≤80) + "/" + ep_dirname(≤64) + NUL ≤ 146
			char ep_dir[FEED_DIR_MAX + 1 + MAX_DIRNAME + 1];
			snprintf(ep_dir, sizeof(ep_dir), "%s/%s", feed_dir, ep_dirname);

			// Skip if already synced (incremental sync)
			char entry_path[STORAGE_MAX_PATH];
			snprintf(entry_path, sizeof(entry_path), "%s/entry.xml", ep_dir);
			storage_file_t check = storage_open(entry_path, "r");
			if (check) {
				storage_close(check);
				status.current_episode++;
				return true;
			}

			storage_mkdir(ep_dir);

			// Save entry.xml (episode metadata as simple XML)
			podcast_write_entry(entry_path, ep);

			status.current_episode++;
			return true;
		}

		case SYNC_NEXT_FEED: {
			if (episodes) {
				free(episodes);
				episodes = NULL;
			}
			episode_count = 0;
			status.current_feed++;

			if (status.current_feed >= feed_count) {
				phase = SYNC_DONE;
			} else {
				phase = SYNC_DOWNLOAD_FEED;
			}
			return true;
		}

		case SYNC_DONE: {
			network_set_enabled(false);
			if (feeds) {
				free(feeds);
				feeds = NULL;
			}
			if (episodes) {
				free(episodes);
				episodes = NULL;
			}
			status.current_action = "Sync complete";
			status.done = true;
			return false;
		}
	}

	return false;
}

const podcast_sync_status_t* podcast_sync_get_status(void) {
	return &status;
}

void podcast_sync_cancel(void) {
	status.cancelled = true;
	status.done = true;
	network_set_enabled(false);
	if (feeds) {
		free(feeds);
		feeds = NULL;
	}
	if (episodes) {
		free(episodes);
		episodes = NULL;
	}
}