Files
RSS-Feed/src/FeedService.php
T
Harwinder Sekhon 41f07b6ad8 Add manual subreddit/YouTube channel configuration (manage.php)
Browser-based feed selection layered on top of config/feeds.php:

- manage.php (password + CSRF gated, session based): add subreddits
  (name / r/name / URL) and YouTube channels (@handle / UC id / URL,
  handle resolved to channel_id once, youtube.com only); enable/disable
  toggles; remove manual feeds; config feeds are hidden, not deleted.
- New feeds are validated with one immediate fetch (bypassing the
  per-refresh host cap): HTTP 404 rejects the entry, 429 keeps it for
  a later refresh. YouTube labels adopt the real channel title.
- feeds table gains origin (config|manual) and enabled columns, with
  a guarded ALTER migration for existing databases.
- Only enabled feeds are refreshed and served: getItems/countItems
  filter by feed_url in enabled feeds, so deselected feeds' cached
  stories are hidden, not deleted; re-adding a URL re-enables it.
- Dashboard header gets a Manage link; manage page styled with the
  existing design tokens; README documents the feature.
2026-10-05 18:58:38 -04:00

118 lines
3.5 KiB
PHP

<?php
declare(strict_types=1);
namespace App;
final class FeedService
{
/**
* @param array<int, array<string, mixed>> $feeds
*/
public function __construct(
private FeedRepository $repository,
private FeedFetcher $fetcher,
private array $feeds,
private int $ttlSeconds,
private int $errorRetryTtlSeconds = 300,
private array $throttledHosts = [],
private int $throttledHostMax = 1,
) {
}
/**
* Refresh every feed whose cached copy is stale. Failures are recorded and
* skipped so previously cached items remain available.
*
* @return array{attempted: int, succeeded: int, failed: int, new_items: int, errors: array<int, string>}
*/
public function refreshStale(): array
{
$this->repository->syncFeeds($this->feeds);
$report = [
'attempted' => 0,
'succeeded' => 0,
'failed' => 0,
'new_items' => 0,
'errors' => [],
];
$hostCounts = [];
$activeFeeds = $this->repository->enabledFeeds();
foreach ($this->repository->staleFeeds($activeFeeds, $this->ttlSeconds, $this->errorRetryTtlSeconds) as $feed) {
$url = (string) $feed['url'];
$host = (string) parse_url($url, PHP_URL_HOST);
if (in_array($host, $this->throttledHosts, true)) {
$seen = $hostCounts[$host] ?? 0;
if ($seen >= $this->throttledHostMax) {
// Leave it stale so a later request picks it up.
continue;
}
$hostCounts[$host] = $seen + 1;
}
$report['attempted']++;
try {
$result = $this->fetcher->fetch($feed);
$report['new_items'] += $this->repository->upsertItems($result['items']);
$this->repository->recordAttempt($url, true);
$report['succeeded']++;
} catch (\Throwable $e) {
$this->repository->recordAttempt($url, false, $e->getMessage());
$report['failed']++;
$report['errors'][] = ($feed['label'] ?? $url) . ': ' . $e->getMessage();
}
}
return $report;
}
/**
* @return array{items: array<int, array<string, mixed>>, page: int, page_size: int, total: int, has_more: bool}
*/
public function page(?string $source, int $page, int $pageSize, ?string $since = null): array
{
$page = max(1, $page);
$pageSize = max(1, $pageSize);
$offset = ($page - 1) * $pageSize;
$items = $this->repository->getItems($pageSize, $offset, $source, $since);
$total = $this->repository->countItems($source, $since);
return [
'items' => $items,
'page' => $page,
'page_size' => $pageSize,
'total' => $total,
'has_more' => ($offset + count($items)) < $total,
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function feedsStatus(): array
{
return $this->repository->feedsStatus();
}
/**
* Sources that currently have at least one enabled feed.
*
* @return array<int, string>
*/
public function sources(): array
{
$sources = [];
foreach ($this->repository->enabledFeeds() as $feed) {
$sources[(string) $feed['source']] = true;
}
return array_keys($sources);
}
}