vrc_log/
lib.rs

1#[macro_use]
2extern crate tracing;
3
4use std::{
5    env::Args,
6    ffi::OsStr,
7    fs::{create_dir_all, File},
8    io::{BufRead, BufReader, Error},
9    path::{Path, PathBuf},
10    process::{Command, Stdio},
11    sync::LazyLock,
12    time::Duration,
13};
14
15use anyhow::{bail, Result};
16use chrono::Local;
17use colored::{Color, Colorize};
18use crossbeam::channel::{Receiver, Sender};
19use lazy_regex::{lazy_regex, regex_replace_all, Lazy, Regex};
20use notify::{Config, Event, PollWatcher, RecursiveMode, Watcher};
21use parking_lot::RwLock;
22use terminal_link::Link;
23
24use crate::{
25    provider::{prelude::*, Provider, ProviderKind},
26    settings::Settings,
27};
28
29#[cfg(feature = "discord")]
30pub mod discord;
31pub mod provider;
32pub mod settings;
33pub mod vrchat;
34#[cfg(windows)]
35pub mod windows;
36
37pub const CARGO_PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
38pub const CARGO_PKG_HOMEPAGE: &str = env!("CARGO_PKG_HOMEPAGE");
39pub const USER_AGENT: &str = concat!(
40    "VRC-LOG/",
41    env!("CARGO_PKG_VERSION"),
42    " shaybox@shaybox.com"
43);
44
45#[must_use]
46pub fn get_local_time() -> String {
47    Local::now().format("%Y-%m-%d %H:%M:%S").to_string()
48}
49
50/// # Errors
51/// Will return `Err` if it couldn't get the GitHub repository.
52pub async fn check_for_updates() -> reqwest::Result<bool> {
53    let response = reqwest::get(CARGO_PKG_HOMEPAGE).await?;
54    if let Some(mut segments) = response.url().path_segments() {
55        if let Some(remote_version) = segments.next_back() {
56            return Ok(remote_version > CARGO_PKG_VERSION);
57        }
58    }
59
60    Ok(false)
61}
62
63/// # Errors
64/// Will return `Err` if `PollWatcher::watch` errors
65pub fn watch<P: AsRef<Path>>(
66    tx: Sender<PathBuf>,
67    path: P,
68    millis: u64,
69) -> notify::Result<PollWatcher> {
70    let path = path.as_ref();
71    debug!("Watching {path:?}");
72
73    let tx_clone = tx.clone();
74    let mut watcher = PollWatcher::with_initial_scan(
75        move |watch_event: notify::Result<Event>| {
76            if let Ok(event) = watch_event {
77                for path in event.paths {
78                    if let Some(extension) = path.extension().and_then(OsStr::to_str) {
79                        if ["csv", "log", "txt"].contains(&extension) {
80                            let _ = tx.send(path.clone());
81                        }
82                    }
83                    if let Some(filename) = path.file_name().and_then(OsStr::to_str) {
84                        if filename == "amplitude.cache" {
85                            let _ = tx.send(path);
86                        }
87                    }
88                }
89            }
90        },
91        Config::default()
92            .with_compare_contents(true)
93            .with_poll_interval(Duration::from_millis(millis)),
94        move |scan_event: notify::Result<PathBuf>| {
95            if let Ok(path) = scan_event {
96                if let Some(extension) = path.extension().and_then(OsStr::to_str) {
97                    if ["csv", "log", "txt"].contains(&extension) {
98                        let _ = tx_clone.send(path.clone());
99                    }
100                }
101                if let Some(filename) = path.file_name().and_then(OsStr::to_str) {
102                    if filename == "amplitude.cache" {
103                        let _ = tx_clone.send(path);
104                    }
105                }
106            }
107        },
108    )?;
109
110    watcher.watch(path, RecursiveMode::NonRecursive)?;
111
112    Ok(watcher)
113}
114
115/// Steam Game Launch Options: `.../vrc-log(.exe) %command%`
116///
117/// # Errors
118/// Will return `Err` if `Command::spawn` errors
119/// # Panics
120/// Will panic if `Child::wait` panics
121pub fn launch_game(args: Args) -> Result<()> {
122    let args = args.collect::<Vec<_>>();
123    if args.len() > 1 {
124        let mut child = Command::new(&args[1])
125            .args(args.iter().skip(2))
126            .stderr(Stdio::null())
127            .stdout(Stdio::null())
128            .spawn()?;
129
130        std::thread::spawn(move || {
131            child.wait().unwrap();
132            std::process::exit(0);
133        });
134    }
135
136    Ok(())
137}
138
139/// # Errors
140/// Will return `Err` if `Sqlite::new` or `Provider::send_avatar_id` errors
141pub async fn process_avatars(
142    settings: Settings,
143    (_tx, rx): (Sender<PathBuf>, Receiver<PathBuf>),
144) -> Result<()> {
145    #[cfg(feature = "cache")]
146    let cache = Cache::new().await?;
147    let providers = settings
148        .providers
149        .iter()
150        .filter_map(|provider| match provider {
151            #[cfg(feature = "cache")]
152            ProviderKind::CACHE => None,
153            #[cfg(feature = "avtrdb")]
154            ProviderKind::AVTRDB => provider!(AvtrDB::new(&settings)),
155            #[cfg(feature = "nsvr")]
156            ProviderKind::NSVR => provider!(NSVR::new(&settings)),
157            #[cfg(feature = "paw")]
158            ProviderKind::PAW => provider!(Paw::new(&settings)),
159            #[cfg(feature = "vrcdb")]
160            ProviderKind::VRCDB => provider!(VrcDB::new(&settings)),
161            #[cfg(feature = "vrcwb")]
162            ProviderKind::VRCWB => provider!(VrcWB::new(&settings)),
163        })
164        .collect::<Vec<_>>();
165
166    while let Ok(path) = rx.recv() {
167        for avatar_id in parse_avatar_ids(&path, settings.clear_amplitude) {
168            #[cfg(feature = "cache")] // Avatar already in cache
169            if !cache.check_avatar_id(&avatar_id).await? {
170                continue;
171            }
172
173            #[cfg(feature = "cache")] // Don't send to cache if sending failed
174            let mut send_to_cache = true;
175
176            print_colorized(&avatar_id);
177
178            // Collect all provider futures for this avatar_id
179            let futures = providers
180                .iter()
181                .map(|provider| provider.send_avatar_id(&avatar_id));
182            let results = futures::future::join_all(futures).await;
183
184            for (provider, result) in providers.iter().zip(results) {
185                let kind = provider.kind();
186                match result {
187                    Ok(unique) => {
188                        if unique {
189                            info!("^ Successfully Submitted to {kind}");
190                        }
191                    }
192                    Err(error) => {
193                        #[cfg(feature = "cache")]
194                        {
195                            send_to_cache = false;
196                        }
197                        error!("^ Failed to submit to {kind}: {error}");
198                    }
199                }
200            }
201
202            #[cfg(feature = "cache")]
203            if send_to_cache {
204                cache.send_avatar_id(&avatar_id).await?;
205            }
206        }
207    }
208
209    bail!("Channel Closed")
210}
211
212/// # Errors
213/// Will return `Err` if `std::fs::canonicalize` errors
214///
215/// # Panics
216/// Will panic if an environment variable doesn't exist
217pub fn parse_path_env(path: &str) -> Result<PathBuf, Error> {
218    let path = regex_replace_all!(r"(?:\$|%)(\w+)%?", path, |_, env| {
219        std::env::var(env).unwrap_or_else(|_| panic!("Environment Variable not found: {env}"))
220    });
221
222    let path = Path::new(path.as_ref());
223    if !path.exists() {
224        if let Some(parent) = path.parent() {
225            create_dir_all(parent)?;
226        }
227        File::create(path)?;
228    }
229
230    std::fs::canonicalize(path)
231}
232
233#[must_use]
234pub fn parse_avatar_ids(path: &PathBuf, clear_amplitude: bool) -> Vec<String> {
235    static RE: Lazy<Regex> = lazy_regex!(r"avtr_\w{8}-\w{4}-\w{4}-\w{4}-\w{12}");
236
237    let Ok(file) = File::open(path) else {
238        return Vec::new(); // Directory
239    };
240
241    let mut reader = BufReader::new(file);
242    let mut avatar_ids = Vec::new();
243    let mut buf = Vec::new();
244
245    while reader.read_until(b'\n', &mut buf).unwrap_or(0) > 0 {
246        let line = String::from_utf8_lossy(&buf);
247        for mat in RE.find_iter(&line) {
248            avatar_ids.push(mat.as_str().to_string());
249        }
250        buf.clear();
251    }
252
253    // Close the file handle before attempting to delete
254    drop(reader);
255
256    // Clear amplitude file after reading if enabled and it's an amplitude file
257    if clear_amplitude && path.file_name().and_then(|n| n.to_str()) == Some("amplitude.cache") {
258        match std::fs::write(path, "") {
259            Ok(()) => debug!("Cleared amplitude file: {path:?}"),
260            Err(error) => warn!("Failed to clear amplitude file: {error}"),
261        }
262    }
263
264    avatar_ids
265}
266
267/// # Print with colorized rainbow rows for separation
268pub fn print_colorized(avatar_id: &str) {
269    static INDEX: LazyLock<RwLock<usize>> = LazyLock::new(|| RwLock::new(0));
270    static COLORS: LazyLock<[Color; 12]> = LazyLock::new(|| {
271        [
272            Color::Red,
273            Color::BrightRed,
274            Color::Yellow,
275            Color::BrightYellow,
276            Color::Green,
277            Color::BrightGreen,
278            Color::Blue,
279            Color::BrightBlue,
280            Color::Cyan,
281            Color::BrightCyan,
282            Color::Magenta,
283            Color::BrightMagenta,
284        ]
285    });
286
287    let index = *INDEX.read();
288    let color = COLORS[index];
289    *INDEX.write() = (index + 1) % COLORS.len();
290
291    let text = format!("vrcx://avatar/{avatar_id}");
292    let link = Link::new(&text, &text).to_string().color(color);
293    info!("{link}");
294}