url_preview/
preview_service.rs

1use crate::github_types::{is_github_url, GitHubDetailedInfo};
2use crate::{
3    is_twitter_url, Fetcher, Preview, PreviewError, PreviewGenerator, UrlPreviewGenerator,
4};
5use std::sync::Arc;
6use tracing::{debug, instrument, warn};
7use url::Url;
8use tokio::sync::Semaphore;
9
10/// PreviewService provides a unified preview generation service
11/// It can automatically identify different types of URLs and use appropriate processing strategies
12#[derive(Clone)]
13pub struct PreviewService {
14    pub default_generator: Arc<UrlPreviewGenerator>,
15    pub twitter_generator: Arc<UrlPreviewGenerator>,
16    pub github_generator: Arc<UrlPreviewGenerator>,
17    // Max Concurrent Requests
18    semaphore: Arc<Semaphore>,
19}
20
21pub const MAX_CONCURRENT_REQUESTS: usize = 500;
22
23impl PreviewService {
24    /// Creates a new preview service instance with default cache capacity
25    pub fn default() -> Self {
26        // Set 1000 cache entries for each generator
27        // This means that up to 1000 different URL previews can be cached for each type (Normal/Twitter/GitHub)
28        // 1000 cache entries take about 1-2MB memory
29        // Total of 3-6MB for three generators is reasonable for modern systems
30        Self::new(1000)
31    }
32
33    pub fn new(cache_capacity: usize) -> Self {
34        debug!(
35            "Initializing PreviewService with cache capacity: {}",
36            cache_capacity
37        );
38
39        let default_generator = Arc::new(UrlPreviewGenerator::new_with_fetcher(
40            cache_capacity,
41            Fetcher::new(),
42        ));
43
44        let twitter_generator = Arc::new(UrlPreviewGenerator::new_with_fetcher(
45            cache_capacity,
46            Fetcher::new_twitter_client(),
47        ));
48
49        let github_generator = Arc::new(
50            UrlPreviewGenerator::new_with_fetcher(cache_capacity, Fetcher::new_github_client()),
51        );
52
53        let semaphore = Arc::new(Semaphore::new(MAX_CONCURRENT_REQUESTS));
54
55        debug!("PreviewService initialized successfully");
56
57        Self {
58            default_generator,
59            twitter_generator,
60            github_generator,
61            semaphore,
62        }
63    }
64
65    pub fn new_with_config(config: PreviewServiceConfig) -> Self {
66        debug!("Initializing PreviewService with custom configuration");
67
68        let default_generator = Arc::new(UrlPreviewGenerator::new_with_fetcher(
69            config.cache_capacity,
70            config.default_fetcher.unwrap_or_else(Fetcher::new),
71        ));
72
73        let twitter_generator = Arc::new(UrlPreviewGenerator::new_with_fetcher(
74            config.cache_capacity,
75            config
76                .twitter_fetcher
77                .unwrap_or_else(Fetcher::new_twitter_client),
78        ));
79
80        let github_generator = Arc::new(
81            UrlPreviewGenerator::new_with_fetcher(
82                config.cache_capacity,
83                config
84                    .github_fetcher
85                    .unwrap_or_else(Fetcher::new_github_client),
86            ),
87        );
88
89        let semaphore = Arc::new(Semaphore::new(config.max_concurrent_requests));
90
91        debug!("PreviewService initialized with custom configuration");
92
93        Self {
94            default_generator,
95            twitter_generator,
96            github_generator,
97            semaphore,
98        }
99    }
100
101    fn extract_github_info(url: &str) -> Option<(String, String)> {
102        let url = Url::parse(url).ok()?;
103        if !url.host_str()?.contains("github.com") {
104            return None;
105        }
106
107        let path_segments: Vec<&str> = url.path_segments()?.collect();
108        if path_segments.len() >= 2 {
109            Some((path_segments[0].to_string(), path_segments[1].to_string()))
110        } else {
111            None
112        }
113    }
114
115    #[instrument(level = "debug", skip(self))]
116    async fn generate_github_preview(&self, url: &str) -> Result<Preview, PreviewError> {
117        if let Some(cached) = self.github_generator.cache.get(url).await {
118            return Ok(cached);
119        }
120
121        let (owner, repo_name) = Self::extract_github_info(url).ok_or_else(|| {
122            warn!("GitHub URL parsing failed: {}", url);
123            PreviewError::ExtractError("Invalid GitHub URL format".into())
124        })?;
125
126        match self
127            .github_generator
128            .fetcher
129            .fetch_github_basic_preview(&owner, &repo_name)
130            .await
131        {
132            Ok(basic_info) => {
133                debug!("Found GitHub Repo {}/{} basic infos", owner, repo_name);
134
135                let preview = Preview {
136                    url: url.to_string(),
137                    title: Some(basic_info.title),
138                    description: basic_info.description,
139                    image_url: basic_info.image_url,
140                    site_name: Some("GitHub".to_string()),
141                    favicon: Some(
142                        "https://github.githubassets.com/favicons/favicon.svg".to_string(),
143                    ),
144                };
145
146                self.github_generator.cache.set(url.to_string(), preview.clone()).await;
147
148                Ok(preview)
149            }
150            Err(e) => {
151                warn!(
152                    error = %e,
153                    "Failed to get GitHub basic preview, will use general preview generator as fallback"
154                );
155                self.github_generator.generate_preview(url).await
156            }
157        }
158    }
159
160    #[instrument(level = "debug", skip(self))]
161    pub async fn generate_preview(&self, url: &str) -> Result<Preview, PreviewError> {
162        debug!("Starting preview generation for URL: {}", url);
163
164        let result = if is_twitter_url(url) {
165            debug!("Detected Twitter URL, using specialized handler");
166            self.twitter_generator.generate_preview(url).await
167        } else if is_github_url(url) {
168            debug!("Detected GitHub URL, using specialized handler");
169            self.generate_github_preview(url).await
170        } else {
171            debug!("Using default URL handler");
172            self.default_generator.generate_preview(url).await
173        };
174
175        // match &result {
176        //     Ok(preview) => {
177        //         log_preview_card(preview, url);
178        //     }
179        //     Err(e) => {
180        //         log_error_card(url, e);
181        //     }
182        // }
183
184        result
185    }
186
187    pub async fn generate_github_basic_preview(&self, url: &str) -> Result<Preview, PreviewError> {
188        let (owner, repo) = Self::extract_github_info(url)
189            .ok_or_else(|| PreviewError::ExtractError("Invalid GitHub URL format".into()))?;
190
191        let basic_info = self
192            .github_generator
193            .fetcher
194            .fetch_github_basic_preview(&owner, &repo)
195            .await?;
196
197        Ok(Preview {
198            url: url.to_string(),
199            title: Some(basic_info.title),
200            description: basic_info.description,
201            image_url: basic_info.image_url,
202            site_name: Some("GitHub".to_string()),
203            favicon: Some("https://github.githubassets.com/favicons/favicon.svg".to_string()),
204        })
205    }
206
207    pub async fn get_github_detailed_info(
208        &self,
209        url: &str,
210    ) -> Result<GitHubDetailedInfo, PreviewError> {
211        let (owner, repo) = Self::extract_github_info(url)
212            .ok_or_else(|| PreviewError::ExtractError("Invalid GitHub URL format".into()))?;
213
214        self.github_generator
215            .fetcher
216            .fetch_github_detailed_info(&owner, &repo)
217            .await
218    }
219}
220
221impl PreviewService {
222    pub fn new_with_concurrency(config: PreviewServiceConfig) -> Self {
223        let semaphore = Arc::new(Semaphore::new(config.max_concurrent_requests));
224        let default_generator = Arc::new(UrlPreviewGenerator::new(config.cache_capacity));
225        let twitter_generator = Arc::new(UrlPreviewGenerator::new(config.cache_capacity));
226        let github_generator = Arc::new(UrlPreviewGenerator::new(config.cache_capacity));
227
228        PreviewService {
229            default_generator,
230            twitter_generator,
231            github_generator,
232            semaphore,
233        }
234    }
235
236    #[instrument(level = "debug", skip(self))]
237    pub async fn generate_preview_with_concurrency(
238        &self,
239        url: &str,
240    ) -> Result<Preview, PreviewError> {
241        let permit = self.semaphore.clone().acquire_owned().await;
242        let preview = self.generate_preview(url).await;
243        drop(permit);
244        preview
245    }
246}
247
248#[derive(Default, Clone)]
249pub struct PreviewServiceConfig {
250    pub cache_capacity: usize,
251    pub default_fetcher: Option<Fetcher>,
252    pub twitter_fetcher: Option<Fetcher>,
253    pub github_fetcher: Option<Fetcher>,
254    pub max_concurrent_requests: usize,
255}
256
257impl PreviewServiceConfig {
258    pub fn new(cache_capacity: usize) -> Self {
259        Self {
260            cache_capacity,
261            default_fetcher: None,
262            twitter_fetcher: None,
263            github_fetcher: None,
264            max_concurrent_requests: MAX_CONCURRENT_REQUESTS,
265        }
266    }
267
268    pub fn with_github_fetcher(mut self, fetcher: Fetcher) -> Self {
269        self.github_fetcher = Some(fetcher);
270        self
271    }
272
273    pub fn with_default_fetcher(mut self, fetcher: Fetcher) -> Self {
274        self.default_fetcher = Some(fetcher);
275        self
276    }
277
278    pub fn with_twitter_fetcher(mut self, fetcher: Fetcher) -> Self {
279        self.twitter_fetcher = Some(fetcher);
280        self
281    }
282}