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