Add GitHub metadata enrichment for catalog apps
Enrich catalog apps with GitHub API data (stars, version, downloads, release date) via two strategies: background drip for repo-level info and on-demand fetch when opening a detail page. - Add github_enrichment module with API calls, asset filtering, and architecture auto-detection for AppImage downloads - DB migrations v14/v15 for GitHub metadata and release asset columns - Extract github_owner/repo from feed links during catalog sync - Display colored stat cards (stars, version, downloads, released) on detail pages with on-demand enrichment - Show stars and version on browse tiles and featured carousel cards - Replace install button with SplitButton dropdown when multiple arch assets available, preferring detected architecture - Disable install button until enrichment completes to prevent stale AppImageHub URL downloads - Keep enrichment banner visible on catalog page until truly complete, showing paused state when rate-limited - Add GitHub token and auto-enrich toggle to preferences
This commit is contained in:
@@ -98,6 +98,16 @@ pub struct CatalogApp {
|
||||
pub icon_url: Option<String>,
|
||||
pub homepage: Option<String>,
|
||||
pub license: Option<String>,
|
||||
pub screenshots: Option<String>,
|
||||
pub github_owner: Option<String>,
|
||||
pub github_repo: Option<String>,
|
||||
pub github_stars: Option<i64>,
|
||||
pub github_downloads: Option<i64>,
|
||||
pub latest_version: Option<String>,
|
||||
pub release_date: Option<String>,
|
||||
pub github_enriched_at: Option<String>,
|
||||
pub github_download_url: Option<String>,
|
||||
pub github_release_assets: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -400,6 +410,22 @@ impl Database {
|
||||
self.migrate_to_v11()?;
|
||||
}
|
||||
|
||||
if current_version < 12 {
|
||||
self.migrate_to_v12()?;
|
||||
}
|
||||
|
||||
if current_version < 13 {
|
||||
self.migrate_to_v13()?;
|
||||
}
|
||||
|
||||
if current_version < 14 {
|
||||
self.migrate_to_v14()?;
|
||||
}
|
||||
|
||||
if current_version < 15 {
|
||||
self.migrate_to_v15()?;
|
||||
}
|
||||
|
||||
// Ensure all expected columns exist (repairs DBs where a migration
|
||||
// was updated after it had already run on this database)
|
||||
self.ensure_columns()?;
|
||||
@@ -838,6 +864,72 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_to_v12(&self) -> SqlResult<()> {
|
||||
let new_columns = [
|
||||
"screenshots TEXT",
|
||||
"license TEXT",
|
||||
];
|
||||
for col in &new_columns {
|
||||
let sql = format!("ALTER TABLE catalog_apps ADD COLUMN {}", col);
|
||||
self.conn.execute(&sql, []).ok();
|
||||
}
|
||||
self.conn.execute(
|
||||
"UPDATE schema_version SET version = ?1",
|
||||
params![12],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_to_v13(&self) -> SqlResult<()> {
|
||||
// Remove duplicate catalog_apps entries, keeping the row with the highest id
|
||||
// (most recent insert) per (source_id, name) pair
|
||||
self.conn.execute_batch(
|
||||
"DELETE FROM catalog_apps WHERE id NOT IN (
|
||||
SELECT MAX(id) FROM catalog_apps GROUP BY source_id, name
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_catalog_apps_source_name
|
||||
ON catalog_apps(source_id, name);
|
||||
UPDATE schema_version SET version = 13;"
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_to_v14(&self) -> SqlResult<()> {
|
||||
let new_columns = [
|
||||
"github_owner TEXT",
|
||||
"github_repo TEXT",
|
||||
"github_stars INTEGER",
|
||||
"github_downloads INTEGER",
|
||||
"release_date TEXT",
|
||||
"github_enriched_at TEXT",
|
||||
];
|
||||
for col in &new_columns {
|
||||
let sql = format!("ALTER TABLE catalog_apps ADD COLUMN {}", col);
|
||||
self.conn.execute(&sql, []).ok();
|
||||
}
|
||||
self.conn.execute(
|
||||
"UPDATE schema_version SET version = ?1",
|
||||
params![14],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_to_v15(&self) -> SqlResult<()> {
|
||||
let new_columns = [
|
||||
"github_download_url TEXT",
|
||||
"github_release_assets TEXT",
|
||||
];
|
||||
for col in &new_columns {
|
||||
let sql = format!("ALTER TABLE catalog_apps ADD COLUMN {}", col);
|
||||
self.conn.execute(&sql, []).ok();
|
||||
}
|
||||
self.conn.execute(
|
||||
"UPDATE schema_version SET version = ?1",
|
||||
params![15],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn upsert_appimage(
|
||||
&self,
|
||||
path: &str,
|
||||
@@ -2069,7 +2161,8 @@ impl Database {
|
||||
limit: i32,
|
||||
) -> SqlResult<Vec<CatalogApp>> {
|
||||
let mut sql = String::from(
|
||||
"SELECT id, name, description, categories, download_url, icon_url, homepage, architecture
|
||||
"SELECT id, name, description, categories, download_url, icon_url, homepage, license, screenshots,
|
||||
github_owner, github_repo, github_stars, github_downloads, latest_version, release_date, github_enriched_at, github_download_url, github_release_assets
|
||||
FROM catalog_apps WHERE 1=1"
|
||||
);
|
||||
let mut params_list: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
||||
@@ -2101,6 +2194,16 @@ impl Database {
|
||||
icon_url: row.get(5)?,
|
||||
homepage: row.get(6)?,
|
||||
license: row.get(7)?,
|
||||
screenshots: row.get(8)?,
|
||||
github_owner: row.get(9)?,
|
||||
github_repo: row.get(10)?,
|
||||
github_stars: row.get(11)?,
|
||||
github_downloads: row.get(12)?,
|
||||
latest_version: row.get(13)?,
|
||||
release_date: row.get(14)?,
|
||||
github_enriched_at: row.get(15)?,
|
||||
github_download_url: row.get(16)?,
|
||||
github_release_assets: row.get(17)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
@@ -2113,7 +2216,8 @@ impl Database {
|
||||
|
||||
pub fn get_catalog_app(&self, id: i64) -> SqlResult<Option<CatalogApp>> {
|
||||
let result = self.conn.query_row(
|
||||
"SELECT id, name, description, categories, download_url, icon_url, homepage, architecture
|
||||
"SELECT id, name, description, categories, download_url, icon_url, homepage, license, screenshots,
|
||||
github_owner, github_repo, github_stars, github_downloads, latest_version, release_date, github_enriched_at, github_download_url, github_release_assets
|
||||
FROM catalog_apps WHERE id = ?1",
|
||||
params![id],
|
||||
|row| {
|
||||
@@ -2126,6 +2230,16 @@ impl Database {
|
||||
icon_url: row.get(5)?,
|
||||
homepage: row.get(6)?,
|
||||
license: row.get(7)?,
|
||||
screenshots: row.get(8)?,
|
||||
github_owner: row.get(9)?,
|
||||
github_repo: row.get(10)?,
|
||||
github_stars: row.get(11)?,
|
||||
github_downloads: row.get(12)?,
|
||||
latest_version: row.get(13)?,
|
||||
release_date: row.get(14)?,
|
||||
github_enriched_at: row.get(15)?,
|
||||
github_download_url: row.get(16)?,
|
||||
github_release_assets: row.get(17)?,
|
||||
})
|
||||
},
|
||||
);
|
||||
@@ -2136,6 +2250,65 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get featured catalog apps. Apps with GitHub stars sort first (by stars desc),
|
||||
/// then unenriched apps get a deterministic shuffle that rotates every 15 minutes.
|
||||
pub fn get_featured_catalog_apps(&self, limit: i32) -> SqlResult<Vec<CatalogApp>> {
|
||||
// Time seed rotates every 15 minutes (900 seconds)
|
||||
let time_seed = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() / 900;
|
||||
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, name, description, categories, download_url, icon_url, homepage, license, screenshots,
|
||||
github_owner, github_repo, github_stars, github_downloads, latest_version, release_date, github_enriched_at, github_download_url, github_release_assets
|
||||
FROM catalog_apps
|
||||
WHERE icon_url IS NOT NULL AND icon_url != ''
|
||||
AND description IS NOT NULL AND description != ''
|
||||
AND screenshots IS NOT NULL AND screenshots != ''"
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(CatalogApp {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
description: row.get(2)?,
|
||||
categories: row.get(3)?,
|
||||
download_url: row.get(4)?,
|
||||
icon_url: row.get(5)?,
|
||||
homepage: row.get(6)?,
|
||||
license: row.get(7)?,
|
||||
screenshots: row.get(8)?,
|
||||
github_owner: row.get(9)?,
|
||||
github_repo: row.get(10)?,
|
||||
github_stars: row.get(11)?,
|
||||
github_downloads: row.get(12)?,
|
||||
latest_version: row.get(13)?,
|
||||
release_date: row.get(14)?,
|
||||
github_enriched_at: row.get(15)?,
|
||||
github_download_url: row.get(16)?,
|
||||
github_release_assets: row.get(17)?,
|
||||
})
|
||||
})?;
|
||||
let mut apps: Vec<CatalogApp> = rows.collect::<SqlResult<Vec<_>>>()?;
|
||||
|
||||
// Enriched apps (with stars) sort first by stars descending,
|
||||
// unenriched apps get the deterministic shuffle after them
|
||||
apps.sort_by(|a, b| {
|
||||
match (a.github_stars, b.github_stars) {
|
||||
(Some(sa), Some(sb)) => sb.cmp(&sa),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => {
|
||||
let ha = (a.id as u64 ^ time_seed).wrapping_mul(0x517cc1b727220a95);
|
||||
let hb = (b.id as u64 ^ time_seed).wrapping_mul(0x517cc1b727220a95);
|
||||
ha.cmp(&hb)
|
||||
}
|
||||
}
|
||||
});
|
||||
apps.truncate(limit as usize);
|
||||
Ok(apps)
|
||||
}
|
||||
|
||||
pub fn get_catalog_categories(&self) -> SqlResult<Vec<(String, u32)>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT categories FROM catalog_apps WHERE categories IS NOT NULL AND categories != ''"
|
||||
@@ -2172,12 +2345,26 @@ impl Database {
|
||||
homepage: Option<&str>,
|
||||
file_size: Option<i64>,
|
||||
architecture: Option<&str>,
|
||||
screenshots: Option<&str>,
|
||||
license: Option<&str>,
|
||||
) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"INSERT OR REPLACE INTO catalog_apps
|
||||
(source_id, name, description, categories, latest_version, download_url, icon_url, homepage, file_size, architecture, cached_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, datetime('now'))",
|
||||
params![source_id, name, description, categories, latest_version, download_url, icon_url, homepage, file_size, architecture],
|
||||
"INSERT INTO catalog_apps
|
||||
(source_id, name, description, categories, latest_version, download_url, icon_url, homepage, file_size, architecture, screenshots, license, cached_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, datetime('now'))
|
||||
ON CONFLICT(source_id, name) DO UPDATE SET
|
||||
description = COALESCE(excluded.description, description),
|
||||
categories = COALESCE(excluded.categories, categories),
|
||||
latest_version = COALESCE(excluded.latest_version, latest_version),
|
||||
download_url = excluded.download_url,
|
||||
icon_url = COALESCE(excluded.icon_url, icon_url),
|
||||
homepage = COALESCE(excluded.homepage, homepage),
|
||||
file_size = COALESCE(excluded.file_size, file_size),
|
||||
architecture = COALESCE(excluded.architecture, architecture),
|
||||
screenshots = COALESCE(excluded.screenshots, screenshots),
|
||||
license = COALESCE(excluded.license, license),
|
||||
cached_at = datetime('now')",
|
||||
params![source_id, name, description, categories, latest_version, download_url, icon_url, homepage, file_size, architecture, screenshots, license],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -2232,6 +2419,125 @@ impl Database {
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn get_catalog_app_by_source_and_name(&self, source_id: i64, name: &str) -> SqlResult<Option<i64>> {
|
||||
let result = self.conn.query_row(
|
||||
"SELECT id FROM catalog_apps WHERE source_id = ?1 AND name = ?2",
|
||||
params![source_id, name],
|
||||
|row| row.get(0),
|
||||
);
|
||||
match result {
|
||||
Ok(id) => Ok(Some(id)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
// --- GitHub enrichment methods ---
|
||||
|
||||
pub fn update_catalog_app_github_repo(
|
||||
&self,
|
||||
app_id: i64,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE catalog_apps SET github_owner = ?2, github_repo = ?3 WHERE id = ?1",
|
||||
params![app_id, owner, repo],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_catalog_app_github_metadata(
|
||||
&self,
|
||||
app_id: i64,
|
||||
stars: i64,
|
||||
pushed_at: Option<&str>,
|
||||
) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE catalog_apps SET github_stars = ?2, github_enriched_at = datetime('now') WHERE id = ?1",
|
||||
params![app_id, stars],
|
||||
)?;
|
||||
// Store pushed_at in release_date if no release info yet
|
||||
if let Some(pushed) = pushed_at {
|
||||
self.conn.execute(
|
||||
"UPDATE catalog_apps SET release_date = COALESCE(release_date, ?2) WHERE id = ?1",
|
||||
params![app_id, pushed],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_catalog_app_release_info(
|
||||
&self,
|
||||
app_id: i64,
|
||||
version: Option<&str>,
|
||||
date: Option<&str>,
|
||||
downloads: Option<i64>,
|
||||
github_download_url: Option<&str>,
|
||||
github_release_assets: Option<&str>,
|
||||
) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE catalog_apps SET
|
||||
latest_version = COALESCE(?2, latest_version),
|
||||
release_date = COALESCE(?3, release_date),
|
||||
github_downloads = COALESCE(?4, github_downloads),
|
||||
github_download_url = COALESCE(?5, github_download_url),
|
||||
github_release_assets = COALESCE(?6, github_release_assets),
|
||||
github_enriched_at = datetime('now')
|
||||
WHERE id = ?1",
|
||||
params![app_id, version, date, downloads, github_download_url, github_release_assets],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_unenriched_catalog_apps(&self, limit: i32) -> SqlResult<Vec<CatalogApp>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, name, description, categories, download_url, icon_url, homepage, license, screenshots,
|
||||
github_owner, github_repo, github_stars, github_downloads, latest_version, release_date, github_enriched_at, github_download_url, github_release_assets
|
||||
FROM catalog_apps
|
||||
WHERE github_owner IS NOT NULL AND github_enriched_at IS NULL
|
||||
ORDER BY id
|
||||
LIMIT ?1"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![limit], |row| {
|
||||
Ok(CatalogApp {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
description: row.get(2)?,
|
||||
categories: row.get(3)?,
|
||||
download_url: row.get(4)?,
|
||||
icon_url: row.get(5)?,
|
||||
homepage: row.get(6)?,
|
||||
license: row.get(7)?,
|
||||
screenshots: row.get(8)?,
|
||||
github_owner: row.get(9)?,
|
||||
github_repo: row.get(10)?,
|
||||
github_stars: row.get(11)?,
|
||||
github_downloads: row.get(12)?,
|
||||
latest_version: row.get(13)?,
|
||||
release_date: row.get(14)?,
|
||||
github_enriched_at: row.get(15)?,
|
||||
github_download_url: row.get(16)?,
|
||||
github_release_assets: row.get(17)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn catalog_enrichment_progress(&self) -> SqlResult<(i64, i64)> {
|
||||
let enriched: i64 = self.conn.query_row(
|
||||
"SELECT COUNT(*) FROM catalog_apps WHERE github_owner IS NOT NULL AND github_enriched_at IS NOT NULL",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
let total_with_github: i64 = self.conn.query_row(
|
||||
"SELECT COUNT(*) FROM catalog_apps WHERE github_owner IS NOT NULL",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok((enriched, total_with_github))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -2402,7 +2708,7 @@ mod tests {
|
||||
[],
|
||||
|row| row.get(0),
|
||||
).unwrap();
|
||||
assert_eq!(version, 11);
|
||||
assert_eq!(version, 15);
|
||||
|
||||
// All tables that should exist after the full v1-v7 migration chain
|
||||
let expected_tables = [
|
||||
|
||||
Reference in New Issue
Block a user