Login
1 branch 0 tags
Ben (Desktop/Arch) Added a testsuite 7a0c574 28 days ago 84 Commits
moon / src / screens / podcast_feed_screen.c
#include "podcast_feed_screen.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "../podcast_xml.h"
#include "../storage.h"
#include "../topbar.h"
#include "files_screen.h"

#define PODCASTS_OPML "/Podcasts/podcasts.xml"

static void cancel_event_cb(lv_event_t* e) {
	(void)e;
	navigate_back(SCREEN_PODCASTS);
}

// Look up feed URL from OPML by matching directory basename
static bool lookup_feed_url(const char* dir,
                            char* out_title,
                            size_t title_size,
                            char* out_url,
                            size_t url_size) {
	const char* basename = strrchr(dir, '/');
	basename = (basename && basename[1]) ? basename + 1 : dir;

	podcast_feed_t* feeds = malloc(PODCAST_MAX_FEEDS * sizeof(podcast_feed_t));
	if (!feeds) {
		return false;
	}

	int count = podcast_parse_opml(PODCASTS_OPML, feeds, PODCAST_MAX_FEEDS);

	// Match by sanitizing each feed title and comparing to basename
	for (int i = 0; i < count; i++) {
		char sanitized[65];
		podcast_sanitize_title(feeds[i].title, sanitized, sizeof(sanitized));

		if (strcmp(sanitized, basename) == 0) {
			snprintf(out_title, title_size, "%s", feeds[i].title);
			snprintf(out_url, url_size, "%s", feeds[i].url);
			free(feeds);
			return true;
		}
	}

	free(feeds);
	return false;
}

static void sync_click_cb(lv_event_t* e) {
	if (lv_event_get_code(e) != LV_EVENT_CLICKED) {
		return;
	}

	podcast_feed_screen_state_t* state = ui_state.podcast_feed;
	if (!state) {
		return;
	}

	// Look up feed URL from OPML
	if (!lookup_feed_url(state->dir, app_state.podcast_title,
	                     sizeof(app_state.podcast_title), app_state.podcast_url,
	                     sizeof(app_state.podcast_url))) {
		return;
	}

	navigate_to(SCREEN_PODCAST_SYNC);
}

static void back_event_cb(lv_event_t* e) {
	if (lv_event_get_code(e) == LV_EVENT_CLICKED) {
		cancel_event_cb(e);
	}
}

static void episode_click_cb(lv_event_t* e) {
	if (lv_event_get_code(e) != LV_EVENT_CLICKED) {
		return;
	}

	podcast_feed_screen_state_t* state = ui_state.podcast_feed;
	if (!state) {
		return;
	}

	int idx = (int)(intptr_t)lv_event_get_user_data(e);
	if (idx < 0 || idx >= state->episode_count) {
		return;
	}

	int n = snprintf(app_state.podcast_episode_dir,
	                 sizeof(app_state.podcast_episode_dir), "%s/%s", state->dir,
	                 state->episode_dirs[idx]);

	if (n <= 0 || (size_t)n >= sizeof(app_state.podcast_episode_dir)) {
		return;
	}

	navigate_to(SCREEN_PODCAST_EPISODE);
}

// Parse RFC 822 month name to 1-12, or 0 if unknown
static int parse_month(const char* m) {
	static const char* months[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun",
	                               "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};
	for (int i = 0; i < 12; i++) {
		if (strncmp(m, months[i], 3) == 0) {
			return i + 1;
		}
	}
	return 0;
}

// Parse RFC 822 date string into YYYYMMDD integer for sorting.
// Format: "Wed, 25 Dec 2024 10:30:00 +0000" or "25 Dec 2024 10:30:00 +0000"
// Returns 0 on parse failure.
static int parse_rfc822_date(const char* date) {
	if (!date || !date[0]) {
		return 0;
	}

	// Skip optional day-of-week
	const char* p = date;
	const char* comma = strchr(p, ',');
	if (comma) {
		p = comma + 1;
	}
	while (*p == ' ') {
		p++;
	}

	int day = 0;
	while (*p >= '0' && *p <= '9') {
		day = day * 10 + (*p - '0');
		p++;
	}
	while (*p == ' ') {
		p++;
	}

	int month = parse_month(p);
	while (*p && *p != ' ') {
		p++;
	}
	while (*p == ' ') {
		p++;
	}

	int year = 0;
	while (*p >= '0' && *p <= '9') {
		year = year * 10 + (*p - '0');
		p++;
	}

	if (year < 1970 || month == 0 || day == 0) {
		return 0;
	}

	return year * 10000 + month * 100 + day;
}

// Format a short date string from RFC 822 date.
// Output like "Dec 25, 2024". Falls back to empty string.
static void format_short_date(const char* rfc822, char* out, size_t out_size) {
	int val = parse_rfc822_date(rfc822);
	if (val == 0 || out_size < 13) {
		out[0] = '\0';
		return;
	}
	int year = val / 10000;
	int month = (val / 100) % 100;
	int day = val % 100;
	static const char* months[] = {"",    "Jan", "Feb", "Mar", "Apr",
	                               "May", "Jun", "Jul", "Aug", "Sep",
	                               "Oct", "Nov", "Dec"};
	snprintf(out, out_size, "%s %d, %d", months[month], day, year);
}

// Episode info for sorting
typedef struct {
	char dirname[STORAGE_MAX_NAME];
	char title[128];
	char date_str[64];
	int date_sort;  // YYYYMMDD
	bool has_audio;
} episode_info_t;

static int episode_date_cmp(const void* a, const void* b) {
	const episode_info_t* ea = a;
	const episode_info_t* eb = b;
	// Sort newest first (descending)
	return eb->date_sort - ea->date_sort;
}

// Check if an episode directory has a downloaded audio file
static bool episode_has_audio(const char* ep_dir) {
	storage_entry_t entries[STORAGE_MAX_DIR_ENTRIES];
	int count = storage_list_dir(ep_dir, entries, STORAGE_MAX_DIR_ENTRIES);
	for (int i = 0; i < count; i++) {
		if (entries[i].type == STORAGE_TYPE_FILE &&
		    is_audio_file(entries[i].name)) {
			return true;
		}
	}
	return false;
}

ui_state_t setup_podcast_feed_screen(lv_obj_t* parent,
                                     const char* dir,
                                     const char* focus_item) {
	podcast_feed_screen_state_t* state =
	    calloc(1, sizeof(podcast_feed_screen_state_t));
	if (!state) {
		return (ui_state_t){0};
	}

	if (dir && dir[0]) {
		strncpy(state->dir, dir, sizeof(state->dir) - 1);
		state->dir[sizeof(state->dir) - 1] = '\0';
	}

	state->list = bvs_list_create(parent);

	// Set topbar title to directory basename
	const char* basename = strrchr(state->dir, '/');
	basename = (basename && basename[1]) ? basename + 1 : "Episodes";
	topbar_set_title(basename);

	lv_obj_t* focus_btn = NULL;

	// Sync button for this feed
	lv_obj_t* sync_btn = bvs_list_add_nav_button(
	    state->list, NULL, "Sync now", sync_click_cb, NULL, cancel_event_cb);
	focus_btn = sync_btn;

	// Collect episode info from directory entries
	storage_entry_t* entries =
	    malloc(STORAGE_MAX_DIR_ENTRIES * sizeof(storage_entry_t));
	int count = entries ? get_sorted_entries(state->dir, entries,
	                                         STORAGE_MAX_DIR_ENTRIES)
	                    : 0;

	episode_info_t* episodes = NULL;
	int ep_count = 0;

	if (count > 0) {
		episodes = malloc((size_t)count * sizeof(episode_info_t));
	}

	if (episodes) {
		for (int i = 0; i < count; i++) {
			if (entries[i].type != STORAGE_TYPE_DIR) {
				continue;
			}

			// Build episode dir path
			char ep_dir[STORAGE_MAX_PATH + STORAGE_MAX_NAME + 2];
			snprintf(ep_dir, sizeof(ep_dir), "%s/%s", state->dir,
			         entries[i].name);

			// Parse entry.xml for episode metadata
			char entry_path[sizeof(ep_dir) + 16];
			snprintf(entry_path, sizeof(entry_path), "%s/entry.xml", ep_dir);
			podcast_episode_t parsed;
			if (!podcast_parse_entry(entry_path, &parsed)) {
				continue;
			}

			episode_info_t* ep = &episodes[ep_count];
			snprintf(ep->dirname, sizeof(ep->dirname), "%s", entries[i].name);
			snprintf(ep->title, sizeof(ep->title), "%s", parsed.title);
			if (!ep->title[0]) {
				strncpy(ep->title, entries[i].name, sizeof(ep->title) - 1);
				ep->title[sizeof(ep->title) - 1] = '\0';
			}
			snprintf(ep->date_str, sizeof(ep->date_str), "%s", parsed.pub_date);
			ep->date_sort = parse_rfc822_date(ep->date_str);

			// Check download status
			ep->has_audio = episode_has_audio(ep_dir);

			ep_count++;
		}

		// Sort by date, newest first
		qsort(episodes, (size_t)ep_count, sizeof(episode_info_t),
		      episode_date_cmp);
	}

	free(entries);

	// Create buttons for episodes (limit to MAX_EPISODE_BUTTONS)
	int btn_count =
	    ep_count > MAX_EPISODE_BUTTONS ? MAX_EPISODE_BUTTONS : ep_count;
	state->episode_count = btn_count;

	for (int i = 0; i < btn_count; i++) {
		episode_info_t* ep = &episodes[i];

		// Store dirname in state for click callback
		strncpy(state->episode_dirs[i], ep->dirname,
		        sizeof(state->episode_dirs[i]) - 1);
		state->episode_dirs[i][sizeof(state->episode_dirs[i]) - 1] = '\0';

		// Build date/status subtitle
		char short_date[20];
		format_short_date(ep->date_str, short_date, sizeof(short_date));

		char subtitle[40];
		if (short_date[0] && ep->has_audio) {
			snprintf(subtitle, sizeof(subtitle), "%s  Ready", short_date);
		} else if (short_date[0]) {
			snprintf(subtitle, sizeof(subtitle), "%s", short_date);
		} else if (ep->has_audio) {
			snprintf(subtitle, sizeof(subtitle), "Ready");
		} else {
			subtitle[0] = '\0';
		}

		lv_obj_t* btn = bvs_list_add_nav_button(
		    state->list, NULL, ep->title, episode_click_cb, (void*)(intptr_t)i,
		    cancel_event_cb);

		// Add fixed subtitle label below the scrolling title
		if (subtitle[0]) {
			lv_obj_set_layout(btn, LV_LAYOUT_NONE);
			lv_obj_set_style_pad_ver(btn, 0, 0);
			lv_obj_set_height(btn, 34);

			lv_obj_t* title_label = lv_obj_get_child(btn, 0);
			lv_obj_set_width(title_label, lv_pct(100));
			lv_obj_set_pos(title_label, 0, 0);
			lv_obj_set_style_max_height(title_label, 18, 0);

			lv_obj_t* sub = lv_label_create(btn);
			lv_label_set_text(sub, subtitle);
			lv_obj_set_width(sub, lv_pct(100));
			lv_obj_set_pos(sub, 0, 16);
			lv_label_set_long_mode(sub, LV_LABEL_LONG_CLIP);
			lv_obj_set_style_text_opa(sub, LV_OPA_60, 0);
		}

		if (!focus_btn) {
			focus_btn = btn;
		}
		if (focus_item && focus_item[0] &&
		    strcmp(ep->dirname, focus_item) == 0) {
			focus_btn = btn;
		}
	}

	free(episodes);

	if (btn_count == 0) {
		bvs_list_add_button(state->list, NULL, "No episodes");
	}

	// Back button
	lv_obj_t* back_btn = bvs_list_add_nav_button(
	    state->list, NULL, "Back", back_event_cb, NULL, cancel_event_cb);

	lv_group_focus_obj(focus_btn ? focus_btn : back_btn);

	return (ui_state_t){.type = SCREEN_PODCAST_FEED, .podcast_feed = state};
}

void free_podcast_feed_screen(podcast_feed_screen_state_t* state) {
	if (!state) {
		return;
	}
	free(state);
}

void update_podcast_feed_screen(podcast_feed_screen_state_t* state) {
	(void)state;
}