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
173
174        // match &result {
175        //     Ok(preview) => {
176        //         log_preview_card(preview, url);
177        //     }
178        //     Err(e) => {
179        //         log_error_card(url, e);
180        //     }
181        // }
182
183        if is_twitter_url(url) {
184            debug!("Detected Twitter URL, using specialized handler");
185            self.twitter_generator.generate_preview(url).await
186        } else if is_github_url(url) {
187            debug!("Detected GitHub URL, using specialized handler");
188            self.generate_github_preview(url).await
189        } else {
190            debug!("Using default URL handler");
191            self.default_generator.generate_preview(url).await
192        }
193    }
194
195    pub async fn generate_github_basic_preview(&self, url: &str) -> Result<Preview, PreviewError> {
196        let (owner, repo) = Self::extract_github_info(url)
197            .ok_or_else(|| PreviewError::ExtractError("Invalid GitHub URL format".into()))?;
198
199        let basic_info = self
200            .github_generator
201            .fetcher
202            .fetch_github_basic_preview(&owner, &repo)
203            .await?;
204
205        Ok(Preview {
206            url: url.to_string(),
207            title: Some(basic_info.title),
208            description: basic_info.description,
209            image_url: basic_info.image_url,
210            site_name: Some("GitHub".to_string()),
211            favicon: Some("https://github.githubassets.com/favicons/favicon.svg".to_string()),
212        })
213    }
214
215    pub async fn get_github_detailed_info(
216        &self,
217        url: &str,
218    ) -> Result<GitHubDetailedInfo, PreviewError> {
219        let (owner, repo) = Self::extract_github_info(url)
220            .ok_or_else(|| PreviewError::ExtractError("Invalid GitHub URL format".into()))?;
221
222        self.github_generator
223            .fetcher
224            .fetch_github_detailed_info(&owner, &repo)
225            .await
226    }
227}
228
229impl PreviewService {
230    pub fn new_with_concurrency(config: PreviewServiceConfig) -> Self {
231        let semaphore = Arc::new(Semaphore::new(config.max_concurrent_requests));
232        let default_generator = Arc::new(UrlPreviewGenerator::new(config.cache_capacity));
233        let twitter_generator = Arc::new(UrlPreviewGenerator::new(config.cache_capacity));
234        let github_generator = Arc::new(UrlPreviewGenerator::new(config.cache_capacity));
235
236        PreviewService {
237            default_generator,
238            twitter_generator,
239            github_generator,
240            semaphore,
241        }
242    }
243
244    #[instrument(level = "debug", skip(self))]
245    pub async fn generate_preview_with_concurrency(
246        &self,
247        url: &str,
248    ) -> Result<Preview, PreviewError> {
249        let permit = self.semaphore.clone().acquire_owned().await;
250        let preview = self.generate_preview(url).await;
251        drop(permit);
252        preview
253    }
254}
255
256#[derive(Default, Clone)]
257pub struct PreviewServiceConfig {
258    pub cache_capacity: usize,
259    pub default_fetcher: Option<Fetcher>,
260    pub twitter_fetcher: Option<Fetcher>,
261    pub github_fetcher: Option<Fetcher>,
262    pub max_concurrent_requests: usize,
263}
264
265impl PreviewServiceConfig {
266    pub fn new(cache_capacity: usize) -> Self {
267        Self {
268            cache_capacity,
269            default_fetcher: None,
270            twitter_fetcher: None,
271            github_fetcher: None,
272            max_concurrent_requests: MAX_CONCURRENT_REQUESTS,
273        }
274    }
275
276    pub fn with_github_fetcher(mut self, fetcher: Fetcher) -> Self {
277        self.github_fetcher = Some(fetcher);
278        self
279    }
280
281    pub fn with_default_fetcher(mut self, fetcher: Fetcher) -> Self {
282        self.default_fetcher = Some(fetcher);
283        self
284    }
285
286    pub fn with_twitter_fetcher(mut self, fetcher: Fetcher) -> Self {
287        self.twitter_fetcher = Some(fetcher);
288        self
289    }
290}