Compare commits
358 Commits
f0fcd91545
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a08d2afe7 | ||
|
|
5224a2eb47 | ||
|
|
e7fb0ed723 | ||
|
|
6a7bc68849 | ||
|
|
27e2bcdbba | ||
|
|
182eb8ac01 | ||
|
|
e2f3bc2ecb | ||
|
|
4f9c7835bf | ||
|
|
87b9d20240 | ||
|
|
708560d2e8 | ||
|
|
cacd45d893 | ||
|
|
602dbe50f0 | ||
|
|
cce6104df3 | ||
|
|
34992242b7 | ||
|
|
aaff7d00c6 | ||
|
|
eb49998593 | ||
|
|
cf04441a69 | ||
|
|
6fac9d6d45 | ||
|
|
2edb12a024 | ||
|
|
7f3ae83b1b | ||
|
|
0b3f1fdc1d | ||
|
|
792e246121 | ||
|
|
0fc3bed6a7 | ||
|
|
c0368b2876 | ||
|
|
4a7528c516 | ||
|
|
97eeccf2bd | ||
|
|
5ab2afa967 | ||
|
|
262b908692 | ||
|
|
89eecbe790 | ||
|
|
27bb3daec4 | ||
|
|
f1eb3c236b | ||
|
|
e7854ac1ac | ||
|
|
ca67eff142 | ||
|
|
0e347234b3 | ||
|
|
11c8c1a48f | ||
|
|
6536fb13b3 | ||
|
|
9789afb12b | ||
|
|
b986faa1d4 | ||
|
|
7124b388fa | ||
|
|
632931f515 | ||
|
|
9739560c03 | ||
|
|
80d874a004 | ||
|
|
64dc7455ee | ||
|
|
9e30eedc77 | ||
|
|
75e28608bd | ||
|
|
e22a3f2d6d | ||
|
|
07b812be64 | ||
|
|
61e38caed5 | ||
|
|
e5a6c8decc | ||
|
|
d856ade32b | ||
|
|
2de6a7d42b | ||
|
|
39e38249b7 | ||
|
|
e924c89573 | ||
|
|
3f57569511 | ||
|
|
23190ee05c | ||
|
|
12053ce6db | ||
|
|
5522f2e37d | ||
|
|
8f885c79d4 | ||
|
|
d7e7f70bd2 | ||
|
|
0e02a1b821 | ||
|
|
cafb990fd4 | ||
|
|
53ac33f856 | ||
|
|
ef57172fdd | ||
|
|
f91f06c45e | ||
|
|
ee6919315b | ||
|
|
b4b57ccfc7 | ||
|
|
36e549b176 | ||
|
|
85c270b906 | ||
|
|
14671d6842 | ||
|
|
a875cec9f6 | ||
|
|
8d4a357edf | ||
|
|
474a4b7f38 | ||
|
|
35cd6a440f | ||
|
|
d9b505e516 | ||
|
|
2d719ad2d7 | ||
|
|
4d2470e028 | ||
|
|
e79fd15b91 | ||
|
|
f8d382568b | ||
|
|
43c22846c5 | ||
|
|
6c542ce6b4 | ||
|
|
d6b1f5d93f | ||
|
|
df01dc36f7 | ||
|
|
629000ba37 | ||
|
|
d864bc8a4e | ||
|
|
a0e0a8e4b1 | ||
|
|
09c06df163 | ||
|
|
dcb5148da6 | ||
|
|
7dd58ebfc4 | ||
|
|
3c2eba8658 | ||
|
|
12af9a89cd | ||
|
|
8a9baa1552 | ||
|
|
d4b96a70ee | ||
|
|
ef4a86d3ca | ||
|
|
68c5f4971c | ||
|
|
77f6d27f5a | ||
|
|
d930958081 | ||
|
|
8dd46954d6 | ||
|
|
0662512ebf | ||
|
|
b2a07b0392 | ||
|
|
499e528697 | ||
|
|
a6be0f33ef | ||
|
|
983e861a63 | ||
|
|
7c73601954 | ||
|
|
43a2d09a55 | ||
|
|
67e7b96758 | ||
|
|
efedc0e6e4 | ||
|
|
ef625527a2 | ||
|
|
28a4c57616 | ||
|
|
d84cc715a8 | ||
|
|
5b2a7430bc | ||
|
|
81b967e811 | ||
|
|
f9ccdd8b33 | ||
|
|
20d069f01f | ||
|
|
37d11034d8 | ||
|
|
29aa6fc007 | ||
|
|
259106fa13 | ||
|
|
23f6571911 | ||
|
|
8e6f115871 | ||
|
|
53737784b7 | ||
|
|
154e3a149e | ||
|
|
611c8a99e7 | ||
|
|
92e43d2449 | ||
|
|
4be7ccc6e1 | ||
|
|
39acd8ef96 | ||
|
|
661a28b6ac | ||
|
|
3f98a9eecb | ||
|
|
3e4f5526b0 | ||
|
|
4d80b827e1 | ||
|
|
b75a2cc298 | ||
|
|
f12f50e787 | ||
|
|
d9fed99104 | ||
|
|
025ee713e3 | ||
|
|
913472ebfb | ||
|
|
584abfd431 | ||
|
|
1b4bc6cb13 | ||
|
|
8effce7c2b | ||
|
|
428307f52d | ||
|
|
5e5838debf | ||
|
|
a096ec66f2 | ||
|
|
c17590ccb3 | ||
|
|
436e33d015 | ||
|
|
8a57d0c2bf | ||
|
|
c7e67a3cba | ||
|
|
31adceb3e9 | ||
|
|
edb23b62ba | ||
|
|
ff18f3eb34 | ||
|
|
c3f994ccbb | ||
|
|
9caec79427 | ||
|
|
7d514895cd | ||
|
|
8f5fc41bd2 | ||
|
|
437deb388b | ||
|
|
23a643b9dc | ||
|
|
6434939a69 | ||
|
|
4f1b58d583 | ||
|
|
bb5f610c60 | ||
|
|
c673a1c22b | ||
|
|
e7b10cbe4f | ||
|
|
53a4c62bfe | ||
|
|
44b42170be | ||
|
|
f10491dd73 | ||
|
|
09adedae72 | ||
|
|
2a32690894 | ||
|
|
59d30695e9 | ||
|
|
c05991ee23 | ||
|
|
61aa6a966e | ||
|
|
24e4c5dfd7 | ||
|
|
c135f60894 | ||
|
|
746147c7c0 | ||
|
|
812d1c205f | ||
|
|
79b833b857 | ||
|
|
87965d4659 | ||
|
|
c0d8b8b2f4 | ||
|
|
0ba1c62daa | ||
|
|
6dd63ae620 | ||
|
|
fef5ee5796 | ||
|
|
07281e8360 | ||
|
|
ee8abaed8d | ||
|
|
d01436ab6a | ||
|
|
caed5088f5 | ||
|
|
b383a36077 | ||
|
|
0f2983ca15 | ||
|
|
f7a836c353 | ||
|
|
e80eb79613 | ||
|
|
750be251c0 | ||
|
|
49ca76ab48 | ||
|
|
2248d11d3e | ||
|
|
5dcc046005 | ||
|
|
9f4e8eeff0 | ||
|
|
7c645bf653 | ||
|
|
60e3db9a8e | ||
|
|
7185d89a64 | ||
|
|
8add6f44aa | ||
|
|
88f1126ec5 | ||
|
|
7d8f0d1b4f | ||
|
|
8017263d21 | ||
|
|
0a1516b82a | ||
|
|
58871d8db9 | ||
|
|
e67025e104 | ||
|
|
ca44f08393 | ||
|
|
5b544dbbf6 | ||
|
|
102fc37683 | ||
|
|
944746bf12 | ||
|
|
673458b630 | ||
|
|
6405596fb8 | ||
|
|
97066a184a | ||
|
|
8944646c85 | ||
|
|
0aee46371a | ||
|
|
0ce2347022 | ||
|
|
3feeb02251 | ||
|
|
6b4b0be522 | ||
|
|
bdc26c8b81 | ||
|
|
e7998f8e19 | ||
|
|
4aba459f04 | ||
|
|
b6f6212de0 | ||
|
|
5dd92b21c4 | ||
|
|
37c534f257 | ||
|
|
bbd4f975eb | ||
|
|
62f467ca68 | ||
|
|
32eb704548 | ||
|
|
d1a4975aa3 | ||
|
|
faa2cea37e | ||
|
|
57ed44c2d4 | ||
|
|
f1a3046f62 | ||
|
|
e18e4da559 | ||
|
|
2d1def2dfe | ||
|
|
859ccd5efb | ||
|
|
323fbfd5c9 | ||
|
|
5f084970d2 | ||
|
|
053575f2c3 | ||
|
|
f88129ff39 | ||
|
|
441780f29b | ||
|
|
7d933384c4 | ||
|
|
bbbb8f5fdf | ||
|
|
5806f5ee2b | ||
|
|
44620a88d5 | ||
|
|
624ee7d782 | ||
|
|
9102a9f43f | ||
|
|
519f178dea | ||
|
|
8a477bffc9 | ||
|
|
41374470b1 | ||
|
|
6ef74955cf | ||
|
|
eafd557d09 | ||
|
|
83fe467252 | ||
|
|
3998c8b1a9 | ||
|
|
4c1776bbcb | ||
|
|
31a31f5733 | ||
|
|
28db17a363 | ||
|
|
90f85dc6e8 | ||
|
|
0b2e1478ea | ||
|
|
13c36a4328 | ||
|
|
b4ee574433 | ||
|
|
9d3d8ce67b | ||
|
|
19a6115eb1 | ||
|
|
19146616dc | ||
|
|
9e1a2a65c9 | ||
|
|
7008e38838 | ||
|
|
ae527041ae | ||
|
|
0a60d12525 | ||
|
|
bd565e044a | ||
|
|
a63e260dac | ||
|
|
f81a0e2ec5 | ||
|
|
bed8882329 | ||
|
|
d77e292dbd | ||
|
|
fe8c564126 | ||
|
|
2c38a2fa6e | ||
|
|
853a24f9cd | ||
|
|
4c5e5028da | ||
|
|
0ebfd6cf10 | ||
|
|
465d1fc99c | ||
|
|
93e090c050 | ||
|
|
0d3e0170d4 | ||
|
|
6df8b3e857 | ||
|
|
1d8b79cb76 | ||
|
|
68c566caa7 | ||
|
|
fe542b970d | ||
|
|
3f391a4516 | ||
|
|
9cf532e831 | ||
|
|
b7a3daebe3 | ||
|
|
97617735e4 | ||
|
|
3c9c9c8cd3 | ||
|
|
d663b344aa | ||
|
|
e1735657f0 | ||
|
|
0a5adac63a | ||
|
|
b94fca9986 | ||
|
|
026266dd83 | ||
|
|
242ce91525 | ||
|
|
23f6df62f0 | ||
|
|
6405cbb269 | ||
|
|
f8fe0aa1ec | ||
|
|
842db68c57 | ||
|
|
c34d6dcc14 | ||
|
|
8cd404d6b1 | ||
|
|
2a912a4010 | ||
|
|
9bec5e4b60 | ||
|
|
0405d2a5ce | ||
|
|
15c8a93990 | ||
|
|
727ceaef4b | ||
|
|
5f4c12e2ff | ||
|
|
a7a107c9b4 | ||
|
|
00b45ecaf9 | ||
|
|
b8423f6731 | ||
|
|
61cf3f625e | ||
|
|
673d9aad5b | ||
|
|
0496954f41 | ||
|
|
578ac3e034 | ||
|
|
f4f22572c1 | ||
|
|
e87a2ed237 | ||
|
|
95eeb273f5 | ||
|
|
69301f1e97 | ||
|
|
ec1d7b8eef | ||
|
|
60a07269f6 | ||
|
|
df323ec9fd | ||
|
|
175c9b748f | ||
|
|
6d08362937 | ||
|
|
52081698e9 | ||
|
|
d837028faf | ||
|
|
cb03417f5f | ||
|
|
d7fc427696 | ||
|
|
3150e57411 | ||
|
|
8d5da3a4dc | ||
|
|
2ddc5e86e2 | ||
|
|
2e8b8bea0c | ||
|
|
082b3b5c1d | ||
|
|
a7610e1bb3 | ||
|
|
261c81e391 | ||
|
|
1324d58f50 | ||
|
|
9399949c36 | ||
|
|
03e4554131 | ||
|
|
c218828d40 | ||
|
|
15c5216309 | ||
|
|
58cff87274 | ||
|
|
e51de99853 | ||
|
|
6b1746180f | ||
|
|
08d7b09e05 | ||
|
|
d74b7b97e6 | ||
|
|
d1b23dd293 | ||
|
|
0f9c23168c | ||
|
|
4cd9661d4b | ||
|
|
91afe6e48f | ||
|
|
ae312a83fb | ||
|
|
4cf29ce201 | ||
|
|
8da7b30c07 | ||
|
|
cae15e7636 | ||
|
|
d2254128d7 | ||
|
|
be83e12bc3 | ||
|
|
babaf90762 | ||
|
|
860eadcbd4 | ||
|
|
ae8fd8e922 | ||
|
|
918ed1a125 | ||
|
|
edc7879324 | ||
|
|
580751af03 | ||
|
|
3fe699b62d | ||
|
|
0cb3531ae4 | ||
|
|
5b9a1b351c | ||
| 20bf6b745b | |||
|
|
5067515933 | ||
| 7fa6bdeb3c | |||
|
|
96b914fb2e |
3
.gitignore
vendored
@@ -3,6 +3,7 @@
|
||||
# will have compiled files and executables
|
||||
debug/
|
||||
target/
|
||||
.testing/
|
||||
|
||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
||||
@@ -20,3 +21,5 @@ Cargo.lock
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
hottub.db
|
||||
migrations/.keep
|
||||
|
||||
36
Cargo.toml
Normal file
@@ -0,0 +1,36 @@
|
||||
[package]
|
||||
name = "hottub"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
cute = "0.3.0"
|
||||
diesel = { version = "2.2.10", features = ["sqlite", "r2d2"] }
|
||||
dotenvy = "0.15.7"
|
||||
env_logger = "0.11.8"
|
||||
error-chain = "0.12.4"
|
||||
futures = "0.3.31"
|
||||
htmlentity = "1.3.2"
|
||||
ntex = { version = "2.15.1", features = ["tokio"] }
|
||||
ntex-files = "2.0.0"
|
||||
serde = "1.0.228"
|
||||
serde_json = "1.0.145"
|
||||
tokio = { version = "1.49", features = ["full"] }
|
||||
wreq = { version = "5.3.0", features = ["full", "cookies", "multipart"] }
|
||||
wreq-util = "2"
|
||||
percent-encoding = "2.3.2"
|
||||
capitalize = "0.3.4"
|
||||
url = "2.5.7"
|
||||
base64 = "0.22.1"
|
||||
scraper = "0.24.0"
|
||||
once_cell = "1.21.3"
|
||||
rustc-hash = "2.1.1"
|
||||
async-trait = "0.1"
|
||||
regex = "1.12.2"
|
||||
titlecase = "3.6.0"
|
||||
dashmap = "6.1.0"
|
||||
|
||||
[lints.rust]
|
||||
unexpected_cfgs = "allow"
|
||||
# Or keep it as a warning but whitelist the cfg:
|
||||
# unexpected_cfgs = { level = "warn", check-cfg = ['cfg(has_error_description_deprecated)'] }
|
||||
32
Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
||||
FROM debian
|
||||
# FROM consol/debian-xfce-vnc:latest
|
||||
# Switch to root user to install additional software
|
||||
USER 0
|
||||
|
||||
RUN apt update
|
||||
RUN apt install -yq libssl-dev \
|
||||
wget curl unzip \
|
||||
openssl \
|
||||
ca-certificates \
|
||||
fontconfig \
|
||||
fonts-dejavu \
|
||||
libxext6 \
|
||||
libxrender1 \
|
||||
libxtst6 \
|
||||
gnupg \
|
||||
supervisor \
|
||||
python3 python3-pip python3-venv\
|
||||
scrot python3-tk python3-dev \
|
||||
libx11-6 libx11-dev libxext-dev libxtst6 \
|
||||
libpng-dev libjpeg-dev libtiff-dev libfreetype6-dev \
|
||||
x11-xserver-utils \
|
||||
xserver-xorg \
|
||||
fluxbox \
|
||||
xvfb \
|
||||
gnome-screenshot \
|
||||
libsqlite3-dev sqlite3 sqlitebrowser \
|
||||
sudo \
|
||||
&& apt-get clean
|
||||
|
||||
USER 1000
|
||||
|
||||
49
README.md
@@ -1,3 +1,52 @@
|
||||
# hottub
|
||||
|
||||
Rust based hottub server
|
||||
|
||||
the following URL:
|
||||
|
||||
[hottub.spacemoehre.de](hottub://source?url=hottub.spacemoehre.de)
|
||||
|
||||
## build it yourself
|
||||
|
||||
Get, Build and Host the docker image:
|
||||
|
||||
```
|
||||
git clone https://gitea.spacemoehre.de/simon/hottub
|
||||
sudo docker build hottub
|
||||
cd hottub && cargo build --release
|
||||
nano docker-compose.yml # adjust compose file
|
||||
sudo docker compose up -d
|
||||
```
|
||||
|
||||
Verify setup, replace the url with your setup url
|
||||
|
||||
```
|
||||
curl -v http://127.0.0.1
|
||||
```
|
||||
|
||||
->
|
||||
|
||||
```
|
||||
* Trying 127.0.0.1:80...
|
||||
* Connected to 127.0.0.1 (127.0.0.1) port 80
|
||||
> GET / HTTP/1.1
|
||||
> Host: 127.0.0.1:80
|
||||
> User-Agent: curl/8.5.0
|
||||
> Accept: */*
|
||||
>
|
||||
< HTTP/1.1 302 Found
|
||||
< content-length: 0
|
||||
< location: hottub://source?url=127.0.0.1:80
|
||||
< date: Mon, 29 Sep 2025 14:58:15 GMT
|
||||
<
|
||||
* Connection #0 to host 127.0.0.1 left intact
|
||||
```
|
||||
|
||||
make sure that you get a code 302 to a `hottub://` url
|
||||
|
||||
## Update via git pull
|
||||
|
||||
To Update (i.e. for new supported sites) do
|
||||
```
|
||||
cd /path/to/hottub && git pull && cargo build --release && sudo docker container restart hottub
|
||||
```
|
||||
281
archive/hentaimoon.rs
Normal file
@@ -0,0 +1,281 @@
|
||||
use crate::util::parse_abbreviated_number;
|
||||
use crate::DbPool;
|
||||
use crate::providers::Provider;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::flaresolverr::{FlareSolverrRequest, Flaresolverr};
|
||||
use crate::util::time::parse_time_to_seconds;
|
||||
use crate::videos::{ServerOptions, VideoItem};
|
||||
use error_chain::error_chain;
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
use std::env;
|
||||
use std::vec;
|
||||
use wreq::{Client, Proxy};
|
||||
use wreq_util::Emulation;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HentaimoonProvider {
|
||||
url: String,
|
||||
}
|
||||
impl HentaimoonProvider {
|
||||
pub fn new() -> Self {
|
||||
HentaimoonProvider {
|
||||
url: "https://hentai-moon.com".to_string(),
|
||||
}
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
sort: &str,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let sort_string = match sort {
|
||||
"popular" => "/most-popular",
|
||||
"top-rated" => "/top-rated",
|
||||
_ => "/latest-updates/",
|
||||
};
|
||||
|
||||
let list_str = match sort {
|
||||
"popular" => "list_videos_common_videos_list",
|
||||
"top-rated" => "list_videos_common_videos_list",
|
||||
_ => "list_videos_most_recent_videos",
|
||||
};
|
||||
|
||||
let video_url = format!("{}{}?mode=async^&function=get_block^&block_id={}^&from={}", self.url, sort_string, list_str, page);
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
items.clone()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let proxy = Proxy::all("http://192.168.0.103:8081").unwrap();
|
||||
let client = Client::builder().cert_verification(false).emulation(Emulation::Firefox136).build()?;
|
||||
|
||||
let mut response = client.get(video_url.clone())
|
||||
// .proxy(proxy.clone())
|
||||
.send().await?;
|
||||
if response.status().is_redirection(){
|
||||
response = client.get(response.headers()["Location"].to_str().unwrap())
|
||||
// .proxy(proxy.clone())
|
||||
.send().await?;
|
||||
}
|
||||
if response.status().is_success() {
|
||||
let text = response.text().await?;
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
} else {
|
||||
let flare_url = env::var("FLARE_URL").expect("FLARE_URL not set");
|
||||
let flare = Flaresolverr::new(flare_url);
|
||||
let result = flare
|
||||
.solve(FlareSolverrRequest {
|
||||
cmd: "request.get".to_string(),
|
||||
url: video_url.clone(),
|
||||
maxTimeout: 60000,
|
||||
})
|
||||
.await;
|
||||
let video_items = match result {
|
||||
Ok(res) => {
|
||||
// println!("FlareSolverr response: {}", res);
|
||||
self.get_video_items_from_html(res.solution.response)
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Error solving FlareSolverr: {}", e);
|
||||
return Err("Failed to solve FlareSolverr".into());
|
||||
}
|
||||
};
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
}
|
||||
async fn query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
query: &str,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let search_string = query.to_lowercase().trim().replace(" ", "-");
|
||||
let video_url = format!("{}/search/{}/?mode=async&function=get_block&block_id=list_videos_videos_list_search_result&q={}&category_ids=&sort_by=&from_videos={}&from_albums={}&", self.url, search_string, search_string, page, page);
|
||||
// Check our Video Cache. If the result is younger than 1 hour, we return it.
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
let _ = cache.check().await;
|
||||
return Ok(items.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let proxy = Proxy::all("http://192.168.0.103:8081").unwrap();
|
||||
let client = Client::builder().cert_verification(false).emulation(Emulation::Firefox136).build()?;
|
||||
|
||||
let mut response = client.get(video_url.clone())
|
||||
// .proxy(proxy.clone())
|
||||
.send().await?;
|
||||
|
||||
if response.status().is_redirection(){
|
||||
|
||||
response = client.get(self.url.clone() + response.headers()["Location"].to_str().unwrap())
|
||||
// .proxy(proxy.clone())
|
||||
.send().await?;
|
||||
}
|
||||
|
||||
if response.status().is_success() {
|
||||
let text = response.text().await?;
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
} else {
|
||||
let flare_url = env::var("FLARE_URL").expect("FLARE_URL not set");
|
||||
let flare = Flaresolverr::new(flare_url);
|
||||
let result = flare
|
||||
.solve(FlareSolverrRequest {
|
||||
cmd: "request.get".to_string(),
|
||||
url: video_url.clone(),
|
||||
maxTimeout: 60000,
|
||||
})
|
||||
.await;
|
||||
let video_items = match result {
|
||||
Ok(res) => self.get_video_items_from_html(res.solution.response),
|
||||
Err(e) => {
|
||||
println!("Error solving FlareSolverr: {}", e);
|
||||
return Err("Failed to solve FlareSolverr".into());
|
||||
}
|
||||
};
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_video_items_from_html(&self, html: String) -> Vec<VideoItem> {
|
||||
if html.is_empty() {
|
||||
println!("HTML is empty");
|
||||
return vec![];
|
||||
}
|
||||
let mut items: Vec<VideoItem> = Vec::new();
|
||||
let raw_videos = html.split("<div class=\"pagination\"").collect::<Vec<&str>>()[0]
|
||||
.split("<div class=\"item \">")
|
||||
.collect::<Vec<&str>>()[1..]
|
||||
.to_vec();
|
||||
for video_segment in &raw_videos {
|
||||
// let vid = video_segment.split("\n").collect::<Vec<&str>>();
|
||||
// for (index, line) in vid.iter().enumerate() {
|
||||
// println!("Line {}: {}", index, line);
|
||||
// }
|
||||
let video_url: String = video_segment.split("<a href=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0].to_string();
|
||||
let mut title = video_segment.split("\" title=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
// html decode
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
let id = video_url.split("/").collect::<Vec<&str>>()[4].to_string();
|
||||
let raw_duration = video_segment.split("<div class=\"duration\">").collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let duration = parse_time_to_seconds(&raw_duration).unwrap_or(0) as u32;
|
||||
|
||||
let thumb = video_segment.split("<img class=\"thumb ").collect::<Vec<&str>>()[1]
|
||||
.split("data-original=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
|
||||
let views_part = video_segment.split("<div class=\"views\">").collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let views = parse_abbreviated_number(&views_part).unwrap_or(0) as u32;
|
||||
|
||||
let video_item = VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
video_url.to_string(),
|
||||
"hentaimoon".to_string(),
|
||||
thumb,
|
||||
duration,
|
||||
)
|
||||
.views(views)
|
||||
;
|
||||
items.push(video_item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
impl Provider for HentaimoonProvider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
pool: DbPool,
|
||||
sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
per_page: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let _ = options;
|
||||
let _ = per_page;
|
||||
let _ = pool;
|
||||
let videos: std::result::Result<Vec<VideoItem>, Error> = match query {
|
||||
Some(q) => {
|
||||
self.query(cache, page.parse::<u8>().unwrap_or(1), &q,)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
self.get(cache, page.parse::<u8>().unwrap_or(1), &sort)
|
||||
.await
|
||||
}
|
||||
};
|
||||
match videos {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
println!("Error fetching videos: {}", e);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
380
archive/spankbang.rs
Normal file
@@ -0,0 +1,380 @@
|
||||
use std::vec;
|
||||
use std::env;
|
||||
use error_chain::error_chain;
|
||||
use futures::future::join_all;
|
||||
use htmlentity::entity::{decode, ICodedDataTrait};
|
||||
use crate::db;
|
||||
use crate::providers::Provider;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::flaresolverr::{FlareSolverrRequest, Flaresolverr};
|
||||
use crate::videos::ServerOptions;
|
||||
use crate::videos::{VideoItem};
|
||||
use crate::DbPool;
|
||||
use std::collections::HashMap;
|
||||
use wreq::Client;
|
||||
use wreq_util::Emulation;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SpankbangProvider {
|
||||
url: String,
|
||||
}
|
||||
impl SpankbangProvider {
|
||||
pub fn new() -> Self {
|
||||
SpankbangProvider {
|
||||
url: "https://spankbang.com/".to_string()
|
||||
}
|
||||
}
|
||||
async fn get(&self, cache:VideoCache, pool: DbPool, page: u8, sort: String) -> Result<Vec<VideoItem>> {
|
||||
|
||||
let url = format!("{}{}/{}/", self.url, sort, page);
|
||||
|
||||
let old_items = match cache.get(&url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 60 {
|
||||
// println!("Cache hit for URL: {}", url);
|
||||
return Ok(items.clone());
|
||||
}
|
||||
else{
|
||||
items.clone()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
let client = Client::builder()
|
||||
.emulation(Emulation::Firefox136)
|
||||
.cert_verification(false)
|
||||
.build()?;
|
||||
|
||||
let response = client.get(url.clone()).send().await?;
|
||||
let mut cookies_string = String::new();
|
||||
if let Some(_) = response.headers().get_all("set-cookie").iter().next() {
|
||||
for _ in response.headers().get_all("set-cookie").iter() {
|
||||
let mut cookies_map = HashMap::new();
|
||||
for value in response.headers().get_all("set-cookie").iter() {
|
||||
if let Ok(cookie_str) = value.to_str() {
|
||||
if let Some((k, v)) = cookie_str.split_once('=') {
|
||||
let key = k.trim();
|
||||
let val = v.split(';').next().unwrap_or("").trim();
|
||||
cookies_map.insert(key.to_string(), val.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
cookies_string = cookies_map
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, v))
|
||||
.collect::<Vec<String>>()
|
||||
.join("; ");
|
||||
}
|
||||
}
|
||||
if response.status().is_success() {
|
||||
let text = response.text().await?;
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone(), &client, cookies_string, pool.clone()).await;
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&url);
|
||||
cache.insert(url.clone(), video_items.clone());
|
||||
} else{
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
} else {
|
||||
let flare_url = env::var("FLARE_URL").expect("FLARE_URL not set");
|
||||
let flare = Flaresolverr::new(flare_url);
|
||||
let result = flare
|
||||
.solve(FlareSolverrRequest {
|
||||
cmd: "request.get".to_string(),
|
||||
url: url.clone(),
|
||||
maxTimeout: 60000,
|
||||
})
|
||||
.await;
|
||||
let video_items = match result {
|
||||
Ok(res) => {
|
||||
// println!("FlareSolverr response: {}", res);
|
||||
self.get_video_items_from_html(res.solution.response, &client,String::new(), pool.clone()).await
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Error solving FlareSolverr: {}", e);
|
||||
return Err("Failed to solve FlareSolverr".into());
|
||||
}
|
||||
};
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&url);
|
||||
cache.insert(url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
}
|
||||
|
||||
async fn query(&self, cache: VideoCache, pool: DbPool, page: u8, query: &str) -> Result<Vec<VideoItem>> {
|
||||
let url = format!("{}s/{}/{}/", self.url, query.replace(" ", "+"), page);
|
||||
|
||||
let old_items = match cache.get(&url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 60 {
|
||||
// println!("Cache hit for URL: {}", url);
|
||||
return Ok(items.clone());
|
||||
}
|
||||
else{
|
||||
items.clone()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
let client = Client::builder()
|
||||
.emulation(Emulation::Firefox136)
|
||||
.cert_verification(false)
|
||||
.build()?;
|
||||
|
||||
let response = client.get(url.clone()).send().await?;
|
||||
let mut cookies_string = String::new();
|
||||
if let Some(_) = response.headers().get_all("set-cookie").iter().next() {
|
||||
for _ in response.headers().get_all("set-cookie").iter() {
|
||||
let mut cookies_map = HashMap::new();
|
||||
for value in response.headers().get_all("set-cookie").iter() {
|
||||
if let Ok(cookie_str) = value.to_str() {
|
||||
if let Some((k, v)) = cookie_str.split_once('=') {
|
||||
let key = k.trim();
|
||||
let val = v.split(';').next().unwrap_or("").trim();
|
||||
cookies_map.insert(key.to_string(), val.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
cookies_string = cookies_map
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, v))
|
||||
.collect::<Vec<String>>()
|
||||
.join("; ");
|
||||
}
|
||||
}
|
||||
if response.status().is_success() {
|
||||
let text = response.text().await?;
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone(), &client, cookies_string, pool.clone()).await;
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&url);
|
||||
cache.insert(url.clone(), video_items.clone());
|
||||
} else{
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
} else {
|
||||
let flare_url = env::var("FLARE_URL").expect("FLARE_URL not set");
|
||||
let flare = Flaresolverr::new(flare_url);
|
||||
let result = flare
|
||||
.solve(FlareSolverrRequest {
|
||||
cmd: "request.get".to_string(),
|
||||
url: url.clone(),
|
||||
maxTimeout: 60000,
|
||||
})
|
||||
.await;
|
||||
let video_items = match result {
|
||||
Ok(res) => {
|
||||
// println!("FlareSolverr response: {}", res);
|
||||
self.get_video_items_from_html(res.solution.response, &client, String::new(), pool.clone()).await
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Error solving FlareSolverr: {}", e);
|
||||
return Err("Failed to solve FlareSolverr".into());
|
||||
}
|
||||
};
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&url);
|
||||
cache.insert(url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_video_url(&self, url:String, client:&Client, cookies: String, pool: DbPool) -> Result<String> {
|
||||
|
||||
let mut conn = pool.get().expect("couldn't get db connection from pool");
|
||||
let db_result = db::get_video(&mut conn,url.clone());
|
||||
drop(conn);
|
||||
match db_result {
|
||||
Ok(Some(video_url)) => {
|
||||
return Ok(video_url);
|
||||
}
|
||||
Ok(None) => (),
|
||||
Err(e) => {
|
||||
println!("Error fetching video from database: {}", e);
|
||||
// return Err(format!("Error fetching video from database: {}", e).into());
|
||||
}
|
||||
}
|
||||
let response = client.get(url.clone()).header("Cookie", cookies.clone()).send().await?;
|
||||
|
||||
let mut response = response;
|
||||
while response.status().as_u16() == 429 {
|
||||
// println!("Received 429 Too Many Requests. Waiting 10 seconds before retrying...");
|
||||
ntex::time::sleep(ntex::time::Seconds(60)).await;
|
||||
response = client.get(url.clone()).header("Cookie", cookies.clone()).send().await?;
|
||||
}
|
||||
|
||||
if response.status().is_success() {
|
||||
let text = response.text().await?;
|
||||
let lines = text.split("\n").collect::<Vec<&str>>();
|
||||
let url_line = lines.iter()
|
||||
.find(|s| s.trim_start().starts_with("<source src=") && s.contains("type=\"video/mp4\""))
|
||||
.unwrap_or(&"");
|
||||
let new_url = url_line.split("src=\"").collect::<Vec<&str>>()[1].split("\"").collect::<Vec<&str>>()[0].to_string();
|
||||
let mut conn = pool.get().expect("couldn't get db connection from pool");
|
||||
let _ = db::insert_video(&mut conn, &url, &new_url);
|
||||
drop(conn);
|
||||
return Ok(new_url)
|
||||
}
|
||||
Err(Error::from("Failed to get video URL"))
|
||||
}
|
||||
|
||||
async fn parse_video_item(
|
||||
&self,
|
||||
mut html: String,
|
||||
client: &Client,
|
||||
cookies: String,
|
||||
pool: DbPool
|
||||
) -> Result<VideoItem> {
|
||||
if html.contains("<!-- Video list block -->") {
|
||||
html = html.split("<!-- Video list block -->").collect::<Vec<&str>>()[0].to_string();
|
||||
}
|
||||
|
||||
let vid = html.split("\n").collect::<Vec<&str>>();
|
||||
if vid.len() > 200 {
|
||||
return Err("Video item has too many lines".into());
|
||||
}
|
||||
// for (index ,line) in vid.iter().enumerate() {
|
||||
// println!("Line {}: {}", index, line);
|
||||
// }
|
||||
let title_line = vid.iter()
|
||||
.find(|s| s.trim_start().starts_with("<a href=") && s.contains("title="))
|
||||
.unwrap_or(&"");
|
||||
let mut title = title_line.split("title=\"").collect::<Vec<&str>>()[1].split("\"").collect::<Vec<&str>>()[0].to_string();
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
|
||||
let thumb_line = vid.iter()
|
||||
.find(|s| s.trim_start().starts_with("data-src=") && s.contains(".jpg\""))
|
||||
.unwrap_or(&"");
|
||||
let thumb = thumb_line.split("data-src=\"").collect::<Vec<&str>>()[1].split("\"").collect::<Vec<&str>>()[0].to_string();
|
||||
// let preview_line = vid.iter()
|
||||
// .find(|s: &&&str| s.trim_start().starts_with("<source data-src=") && s.contains("mp4"))
|
||||
// .unwrap_or(&"");
|
||||
// let mut preview = "".to_string();
|
||||
// if vid[15].contains("data-preview=\""){
|
||||
// preview = vid[15].split("data-preview=\"").collect::<Vec<&str>>()[1].split("\"").collect::<Vec<&str>>()[0].to_string();
|
||||
// }
|
||||
// else{
|
||||
// preview = preview_line.split("data-src=\"").collect::<Vec<&str>>()[1].split("\"").collect::<Vec<&str>>()[0].to_string();
|
||||
// }
|
||||
let duration_str = vid[64].split("m").collect::<Vec<&str>>()[0];
|
||||
let duration: u32 = duration_str.parse::<u32>().unwrap_or(0) * 60;
|
||||
// let view_and_rating_str: Vec<&str> = vid.iter().copied().filter(|s| s.contains("<span class=\"md:text-body-md\">")).collect();
|
||||
// let views_str = view_and_rating_str[0].split(">").collect::<Vec<&str>>()[1].split("K<").collect::<Vec<&str>>()[0];
|
||||
// let views = (views_str.parse::<f32>().unwrap_or(0.0) * 1000.0) as u32;
|
||||
// let rate_str = view_and_rating_str[1].split(">").collect::<Vec<&str>>()[1].split("%<").collect::<Vec<&str>>()[0];
|
||||
// let rating = rate_str.parse::<f32>().unwrap_or(0.0);
|
||||
let url_part = vid.iter().find(|s| s.contains("<a href=\"/")).unwrap().split("<a href=\"/").collect::<Vec<&str>>()[1].split("\"").collect::<Vec<&str>>()[0];
|
||||
let url = match self.get_video_url(self.url.clone() + url_part, client, cookies, pool).await {
|
||||
Ok(video_url) => video_url,
|
||||
Err(e) => {
|
||||
print!("Error fetching video URL: {}", e);
|
||||
return Err("Failed to get video URL".into());
|
||||
}
|
||||
};
|
||||
let id = url_part.split("/").collect::<Vec<&str>>()[0].to_string();
|
||||
|
||||
// let quality_str = match vid[25].contains("<"){
|
||||
// true => vid[25].split(">").collect::<Vec<&str>>()[1].split("<").collect::<Vec<&str>>()[0],
|
||||
// false => "SD",
|
||||
// };
|
||||
// let quality = match quality_str{
|
||||
// "HD" => "1080",
|
||||
// "4k" => "2160",
|
||||
// "SD" => "720",
|
||||
// _ => "1080",
|
||||
// };
|
||||
|
||||
let video_item = VideoItem::new(id, title, url.clone().to_string(), "spankbang".to_string(), thumb, duration)
|
||||
// .views(views)
|
||||
// .rating(rating)
|
||||
// .formats(vec![format])
|
||||
// .preview(preview)
|
||||
;
|
||||
Ok(video_item)
|
||||
}
|
||||
|
||||
async fn get_video_items_from_html(&self, html: String, client: &Client, cookies:String, pool: DbPool) -> Vec<VideoItem> {
|
||||
if html.is_empty() {
|
||||
println!("HTML is empty");
|
||||
return vec![];
|
||||
}
|
||||
let items: Vec<VideoItem> = Vec::new();
|
||||
let split_html = html.split("\"video-list").collect::<Vec<&str>>();
|
||||
if split_html.len() < 2 {
|
||||
println!("Could not find video-list in HTML");
|
||||
return items;
|
||||
}
|
||||
let video_listing_content = format!("{}{}", split_html[1], split_html.get(2).unwrap_or(&""));
|
||||
let raw_videos_vec = video_listing_content
|
||||
.split("data-testid=\"video-item\"")
|
||||
.collect::<Vec<&str>>();
|
||||
if raw_videos_vec.len() < 2 {
|
||||
println!("Could not find video-item in HTML");
|
||||
return items;
|
||||
}
|
||||
let raw_videos = raw_videos_vec[1..].to_vec();
|
||||
let futures = raw_videos.into_iter().map(|el| self.parse_video_item(el.to_string(), client, cookies.clone(), pool.clone()));
|
||||
let results: Vec<Result<VideoItem>> = join_all(futures).await;
|
||||
let video_items: Vec<VideoItem> = results
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
.collect();
|
||||
return video_items;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl Provider for SpankbangProvider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
pool: DbPool,
|
||||
mut sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
per_page: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let _ = options;
|
||||
let _ = per_page;
|
||||
let _ = pool;
|
||||
|
||||
if sort == "date"{
|
||||
sort = "trending_videos".to_string();
|
||||
}
|
||||
let videos: std::result::Result<Vec<VideoItem>, Error> = match query {
|
||||
Some(q) => self.query(cache, pool, page.parse::<u8>().unwrap_or(1), &q).await,
|
||||
None => self.get(cache, pool, page.parse::<u8>().unwrap_or(1), sort).await,
|
||||
};
|
||||
match videos {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
println!("Error fetching videos: {}", e);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
burp/accept.png
Normal file
|
After Width: | Height: | Size: 698 B |
BIN
burp/close.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
burp/http_history.png
Normal file
|
After Width: | Height: | Size: 780 B |
BIN
burp/next_button.png
Normal file
|
After Width: | Height: | Size: 526 B |
20
burp/project_options.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"proxy":{
|
||||
"request_listeners":[
|
||||
{
|
||||
"certificate_mode":"per_host",
|
||||
"custom_tls_protocols":[
|
||||
"SSLv3",
|
||||
"TLSv1",
|
||||
"TLSv1.1",
|
||||
"TLSv1.2",
|
||||
"TLSv1.3"
|
||||
],
|
||||
"listen_mode":"all_interfaces",
|
||||
"listener_port":8080,
|
||||
"running":true,
|
||||
"use_custom_tls_protocols":false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
burp/proxy.png
Normal file
|
After Width: | Height: | Size: 780 B |
BIN
burp/sort.png
Normal file
|
After Width: | Height: | Size: 379 B |
BIN
burp/start_burp.png
Normal file
|
After Width: | Height: | Size: 818 B |
98
burp/start_burp.py
Normal file
@@ -0,0 +1,98 @@
|
||||
import pyautogui
|
||||
import time
|
||||
import os
|
||||
import subprocess
|
||||
import datetime
|
||||
|
||||
BURP_JAR = "/headless/burpsuite_community.jar"
|
||||
CONFIG_FILE = "/app/burp/project_options.json"
|
||||
|
||||
def start_burp():
|
||||
os.system("rm -rf /tmp/burp*")
|
||||
burp_process = subprocess.Popen([
|
||||
"java", "-jar", BURP_JAR,
|
||||
f"--config-file={CONFIG_FILE}"
|
||||
])
|
||||
return burp_process
|
||||
|
||||
time.sleep(5)
|
||||
|
||||
print("Starting Burp Suite...")
|
||||
burp_process = start_burp()
|
||||
end_time = datetime.datetime.now() + datetime.timedelta(days=1)
|
||||
button = None
|
||||
proxy_clicked = False
|
||||
history_clicked = False
|
||||
sort_clicked = False
|
||||
setup = False
|
||||
while True:
|
||||
if datetime.datetime.now() > end_time:
|
||||
|
||||
setup = False
|
||||
print("Burp Suite has been running for 24 hours, restarting...")
|
||||
burp_process.terminate()
|
||||
time.sleep(1)
|
||||
burp_process = start_burp()
|
||||
end_time = datetime.datetime.now() + datetime.timedelta(days=1)
|
||||
proxy_clicked = False
|
||||
history_clicked = False
|
||||
sort_clicked = False
|
||||
if not setup:
|
||||
try:
|
||||
button = pyautogui.locateCenterOnScreen("/app/burp/next_button.png", confidence=0.8)
|
||||
except:
|
||||
pass
|
||||
if button:
|
||||
print("Clicking on the 'Next' button...")
|
||||
pyautogui.click(button)
|
||||
button = None
|
||||
|
||||
try:
|
||||
button = pyautogui.locateCenterOnScreen("/app/burp/start_burp.png", confidence=0.8)
|
||||
except:
|
||||
pass
|
||||
if button:
|
||||
print("Clicking on the 'Start Burp' button...")
|
||||
pyautogui.click(button)
|
||||
button = None
|
||||
|
||||
try:
|
||||
button = pyautogui.locateCenterOnScreen("/app/burp/accept.png", confidence=0.8)
|
||||
except:
|
||||
pass
|
||||
if button:
|
||||
print("Clicking on the 'Accept' button...")
|
||||
pyautogui.click(button)
|
||||
button = None
|
||||
|
||||
try:
|
||||
button = pyautogui.locateCenterOnScreen("/app/burp/proxy.png", confidence=0.8)
|
||||
except:
|
||||
pass
|
||||
if button and not proxy_clicked:
|
||||
print("Clicking on the 'Proxy' button...")
|
||||
pyautogui.click(button)
|
||||
proxy_clicked = True
|
||||
button = None
|
||||
|
||||
try:
|
||||
button = pyautogui.locateCenterOnScreen("/app/burp/http_history.png", confidence=0.8)
|
||||
except:
|
||||
pass
|
||||
if button and not history_clicked:
|
||||
print("Clicking on the 'HTTP History' button...")
|
||||
pyautogui.click(button)
|
||||
history_clicked = True
|
||||
button = None
|
||||
try:
|
||||
button = pyautogui.locateCenterOnScreen("/app/burp/sort.png", confidence=0.99)
|
||||
except:
|
||||
pass
|
||||
if button and not sort_clicked:
|
||||
sort_clicked = True
|
||||
print("Clicking on the 'Sorting' button...")
|
||||
pyautogui.click(button)
|
||||
setup = True
|
||||
button = None
|
||||
else:
|
||||
time.sleep(3600)
|
||||
9
diesel.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
# For documentation on how to configure this file,
|
||||
# see https://diesel.rs/guides/configuring-diesel-cli
|
||||
|
||||
[print_schema]
|
||||
file = "src/schema.rs"
|
||||
custom_type_derives = ["diesel::query_builder::QueryId", "Clone"]
|
||||
|
||||
[migrations_directory]
|
||||
dir = "migrations"
|
||||
58
docker-compose.yml
Normal file
@@ -0,0 +1,58 @@
|
||||
services:
|
||||
hottub:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: hottub
|
||||
entrypoint: supervisord
|
||||
command: ["-c", "/app/supervisord/supervisord.conf"]
|
||||
# In case you dont want the burpsuite proxy and only wanna run the server in the docker without compiling outside:
|
||||
# entrypoint: cargo
|
||||
# command: ["run"]
|
||||
volumes:
|
||||
- /path/to/hottub:/app # REPLACE
|
||||
environment:
|
||||
- RUST_LOG=info
|
||||
- BURP_URL=http://127.0.0.1:8081 # local burpsuite proxy for crawler analysis
|
||||
- PROXY=0 # 1 for enable, else disabled
|
||||
- DATABASE_URL=hottub.db # sqlite db to store hard to get videos for easy access
|
||||
- FLARE_URL=http://flaresolverr:8191/v1 # flaresolverr to get around cloudflare 403 codes
|
||||
- DOMAIN=hottub.spacemoehre.de # optional for the 302 forward on "/" to
|
||||
restart: unless-stopped
|
||||
working_dir: /app
|
||||
ports:
|
||||
- 80:18080
|
||||
- 6901:6901 # vnc port to access burpsuite
|
||||
- 8081:8080 # burpsuite port of http(s) proxy
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m" # Maximum size of each log file (e.g., 10MB)
|
||||
max-file: "3" # Maximum number of log files to keep
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:18080/api/status | grep -q 200"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 1s
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 65536
|
||||
hard: 65536
|
||||
|
||||
# flaresolverr to bypass cloudflare protections
|
||||
flaresolverr:
|
||||
container_name: flaresolverr
|
||||
ports:
|
||||
- 8191:8191
|
||||
restart: unless-stopped
|
||||
image: alexfozor/flaresolverr:pr-1300-experimental # master branches dont work as good as this one
|
||||
environment:
|
||||
- LOG_LEVEL=debug
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m" # Maximum size of each log file (e.g., 10MB)
|
||||
max-file: "3" # Maximum number of log files to keep
|
||||
|
||||
|
||||
2
migrations/create_videos/down.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- This file should undo anything in `up.sql`
|
||||
DROP TABLE videos
|
||||
8
migrations/create_videos/up.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- Your SQL goes here
|
||||
CREATE TABLE videos (
|
||||
id TEXT NOT NULL PRIMARY KEY, -- like url parts to uniquely identify a video
|
||||
url TEXT NOT NULL--,
|
||||
--views INTEGER,
|
||||
--rating INTEGER,
|
||||
--uploader TEXT
|
||||
)
|
||||
426
sf-symbols.md
Normal file
@@ -0,0 +1,426 @@
|
||||
# sf-symbols-online
|
||||
|
||||
This table is for GitHub's dark mode. For light mode visit [README.md](./README.md).
|
||||
|
||||
<!--prettier-ignore-start-->
|
||||
| Glyph | Name | Glyph | Name | Glyph | Name | Glyph | Name |
|
||||
|----|-------|----|-------|----|-------|----|-------|
|
||||
| <img alt='square.and.arrow.up' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.and.arrow.up.png'> | square.and.arrow.up | <img alt='square.and.arrow.up.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.and.arrow.up.fill.png'> | square.and.arrow.up.fill | <img alt='square.and.arrow.down' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.and.arrow.down.png'> | square.and.arrow.down | <img alt='square.and.arrow.down.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.and.arrow.down.fill.png'> | square.and.arrow.down.fill | <img alt='square.and.arrow.up.on.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.and.arrow.up.on.square.png'> | square.and.arrow.up.on.square |
|
||||
| <img alt='square.and.arrow.up.on.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.and.arrow.up.on.square.fill.png'> | square.and.arrow.up.on.square.fill | <img alt='square.and.arrow.down.on.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.and.arrow.down.on.square.png'> | square.and.arrow.down.on.square | <img alt='square.and.arrow.down.on.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.and.arrow.down.on.square.fill.png'> | square.and.arrow.down.on.square.fill | <img alt='pencil' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/pencil.png'> | pencil |
|
||||
| <img alt='pencil.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/pencil.circle.png'> | pencil.circle | <img alt='pencil.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/pencil.circle.fill.png'> | pencil.circle.fill | <img alt='pencil.slash' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/pencil.slash.png'> | pencil.slash | <img alt='square.and.pencil' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.and.pencil.png'> | square.and.pencil |
|
||||
| <img alt='pencil.and.ellipsis.rectangle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/pencil.and.ellipsis.rectangle.png'> | pencil.and.ellipsis.rectangle | <img alt='pencil.and.outline' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/pencil.and.outline.png'> | pencil.and.outline | <img alt='trash' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/trash.png'> | trash | <img alt='trash.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/trash.fill.png'> | trash.fill |
|
||||
| <img alt='trash.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/trash.circle.png'> | trash.circle | <img alt='trash.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/trash.circle.fill.png'> | trash.circle.fill | <img alt='trash.slash' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/trash.slash.png'> | trash.slash | <img alt='trash.slash.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/trash.slash.fill.png'> | trash.slash.fill |
|
||||
| <img alt='folder' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/folder.png'> | folder | <img alt='folder.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/folder.fill.png'> | folder.fill | <img alt='folder.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/folder.circle.png'> | folder.circle | <img alt='folder.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/folder.circle.fill.png'> | folder.circle.fill |
|
||||
| <img alt='folder.badge.plus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/folder.badge.plus.png'> | folder.badge.plus | <img alt='folder.fill.badge.plus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/folder.fill.badge.plus.png'> | folder.fill.badge.plus | <img alt='folder.badge.minus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/folder.badge.minus.png'> | folder.badge.minus | <img alt='folder.fill.badge.minus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/folder.fill.badge.minus.png'> | folder.fill.badge.minus |
|
||||
| <img alt='folder.badge.person.crop' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/folder.badge.person.crop.png'> | folder.badge.person.crop | <img alt='folder.fill.badge.person.crop' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/folder.fill.badge.person.crop.png'> | folder.fill.badge.person.crop | <img alt='paperplane' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/paperplane.png'> | paperplane | <img alt='paperplane.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/paperplane.fill.png'> | paperplane.fill |
|
||||
| <img alt='tray' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/tray.png'> | tray | <img alt='tray.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/tray.fill.png'> | tray.fill | <img alt='tray.and.arrow.up' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/tray.and.arrow.up.png'> | tray.and.arrow.up | <img alt='tray.and.arrow.up.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/tray.and.arrow.up.fill.png'> | tray.and.arrow.up.fill |
|
||||
| <img alt='tray.and.arrow.down' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/tray.and.arrow.down.png'> | tray.and.arrow.down | <img alt='tray.and.arrow.down.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/tray.and.arrow.down.fill.png'> | tray.and.arrow.down.fill | <img alt='tray.2' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/tray.2.png'> | tray.2 | <img alt='tray.2.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/tray.2.fill.png'> | tray.2.fill |
|
||||
| <img alt='tray.full' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/tray.full.png'> | tray.full | <img alt='tray.full.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/tray.full.fill.png'> | tray.full.fill | <img alt='archivebox' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/archivebox.png'> | archivebox | <img alt='archivebox.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/archivebox.fill.png'> | archivebox.fill |
|
||||
| <img alt='bin.xmark' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bin.xmark.png'> | bin.xmark | <img alt='bin.xmark.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bin.xmark.fill.png'> | bin.xmark.fill | <img alt='arrow.up.bin' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.bin.png'> | arrow.up.bin | <img alt='arrow.up.bin.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.bin.fill.png'> | arrow.up.bin.fill |
|
||||
| <img alt='doc' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/doc.png'> | doc | <img alt='doc.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/doc.fill.png'> | doc.fill | <img alt='doc.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/doc.circle.png'> | doc.circle | <img alt='doc.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/doc.circle.fill.png'> | doc.circle.fill |
|
||||
| <img alt='arrow.up.doc' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.doc.png'> | arrow.up.doc | <img alt='arrow.up.doc.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.doc.fill.png'> | arrow.up.doc.fill | <img alt='arrow.down.doc' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.down.doc.png'> | arrow.down.doc | <img alt='arrow.down.doc.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.down.doc.fill.png'> | arrow.down.doc.fill |
|
||||
| <img alt='doc.text' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/doc.text.png'> | doc.text | <img alt='doc.text.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/doc.text.fill.png'> | doc.text.fill | <img alt='doc.on.doc' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/doc.on.doc.png'> | doc.on.doc | <img alt='doc.on.doc.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/doc.on.doc.fill.png'> | doc.on.doc.fill |
|
||||
| <img alt='doc.on.clipboard' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/doc.on.clipboard.png'> | doc.on.clipboard | <img alt='doc.on.clipboard.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/doc.on.clipboard.fill.png'> | doc.on.clipboard.fill | <img alt='doc.richtext' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/doc.richtext.png'> | doc.richtext | <img alt='doc.plaintext' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/doc.plaintext.png'> | doc.plaintext |
|
||||
| <img alt='doc.append' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/doc.append.png'> | doc.append | <img alt='doc.text.magnifyingglass' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/doc.text.magnifyingglass.png'> | doc.text.magnifyingglass | <img alt='calendar' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/calendar.png'> | calendar | <img alt='calendar.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/calendar.circle.png'> | calendar.circle |
|
||||
| <img alt='calendar.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/calendar.circle.fill.png'> | calendar.circle.fill | <img alt='calendar.badge.plus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/calendar.badge.plus.png'> | calendar.badge.plus | <img alt='calendar.badge.minus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/calendar.badge.minus.png'> | calendar.badge.minus | <img alt='arrowshape.turn.up.left' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrowshape.turn.up.left.png'> | arrowshape.turn.up.left |
|
||||
| <img alt='arrowshape.turn.up.left.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrowshape.turn.up.left.fill.png'> | arrowshape.turn.up.left.fill | <img alt='arrowshape.turn.up.left.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrowshape.turn.up.left.circle.png'> | arrowshape.turn.up.left.circle | <img alt='arrowshape.turn.up.left.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrowshape.turn.up.left.circle.fill.png'> | arrowshape.turn.up.left.circle.fill | <img alt='arrowshape.turn.up.right' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrowshape.turn.up.right.png'> | arrowshape.turn.up.right |
|
||||
| <img alt='arrowshape.turn.up.right.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrowshape.turn.up.right.fill.png'> | arrowshape.turn.up.right.fill | <img alt='arrowshape.turn.up.right.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrowshape.turn.up.right.circle.png'> | arrowshape.turn.up.right.circle | <img alt='arrowshape.turn.up.right.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrowshape.turn.up.right.circle.fill.png'> | arrowshape.turn.up.right.circle.fill | <img alt='arrowshape.turn.up.left.2' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrowshape.turn.up.left.2.png'> | arrowshape.turn.up.left.2 |
|
||||
| <img alt='arrowshape.turn.up.left.2.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrowshape.turn.up.left.2.fill.png'> | arrowshape.turn.up.left.2.fill | <img alt='book' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/book.png'> | book | <img alt='book.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/book.fill.png'> | book.fill | <img alt='book.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/book.circle.png'> | book.circle |
|
||||
| <img alt='book.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/book.circle.fill.png'> | book.circle.fill | <img alt='bookmark' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bookmark.png'> | bookmark | <img alt='bookmark.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bookmark.fill.png'> | bookmark.fill | <img alt='rosette' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rosette.png'> | rosette |
|
||||
| <img alt='paperclip' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/paperclip.png'> | paperclip | <img alt='paperclip.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/paperclip.circle.png'> | paperclip.circle | <img alt='paperclip.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/paperclip.circle.fill.png'> | paperclip.circle.fill | <img alt='rectangle.and.paperclip' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.and.paperclip.png'> | rectangle.and.paperclip |
|
||||
| <img alt='link' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/link.png'> | link | <img alt='link.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/link.circle.png'> | link.circle | <img alt='link.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/link.circle.fill.png'> | link.circle.fill | <img alt='personalhotspot' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/personalhotspot.png'> | personalhotspot |
|
||||
| <img alt='pencil.tip' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/pencil.tip.png'> | pencil.tip | <img alt='pencil.tip.crop.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/pencil.tip.crop.circle.png'> | pencil.tip.crop.circle | <img alt='pencil.tip.crop.circle.badge.plus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/pencil.tip.crop.circle.badge.plus.png'> | pencil.tip.crop.circle.badge.plus | <img alt='pencil.tip.crop.circle.badge.minus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/pencil.tip.crop.circle.badge.minus.png'> | pencil.tip.crop.circle.badge.minus |
|
||||
| <img alt='person' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/person.png'> | person | <img alt='person.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/person.fill.png'> | person.fill | <img alt='person.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/person.circle.png'> | person.circle | <img alt='person.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/person.circle.fill.png'> | person.circle.fill |
|
||||
| <img alt='person.badge.plus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/person.badge.plus.png'> | person.badge.plus | <img alt='person.badge.plus.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/person.badge.plus.fill.png'> | person.badge.plus.fill | <img alt='person.badge.minus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/person.badge.minus.png'> | person.badge.minus | <img alt='person.badge.minus.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/person.badge.minus.fill.png'> | person.badge.minus.fill |
|
||||
| <img alt='person.2' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/person.2.png'> | person.2 | <img alt='person.2.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/person.2.fill.png'> | person.2.fill | <img alt='person.3' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/person.3.png'> | person.3 | <img alt='person.3.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/person.3.fill.png'> | person.3.fill |
|
||||
| <img alt='person.crop.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/person.crop.circle.png'> | person.crop.circle | <img alt='person.crop.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/person.crop.circle.fill.png'> | person.crop.circle.fill | <img alt='person.crop.circle.badge.plus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/person.crop.circle.badge.plus.png'> | person.crop.circle.badge.plus | <img alt='person.crop.circle.fill.badge.plus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/person.crop.circle.fill.badge.plus.png'> | person.crop.circle.fill.badge.plus |
|
||||
| <img alt='person.crop.circle.badge.minus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/person.crop.circle.badge.minus.png'> | person.crop.circle.badge.minus | <img alt='person.crop.circle.fill.badge.minus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/person.crop.circle.fill.badge.minus.png'> | person.crop.circle.fill.badge.minus | <img alt='person.crop.circle.badge.checkmark' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/person.crop.circle.badge.checkmark.png'> | person.crop.circle.badge.checkmark | <img alt='person.crop.circle.fill.badge.checkmark' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/person.crop.circle.fill.badge.checkmark.png'> | person.crop.circle.fill.badge.checkmark |
|
||||
| <img alt='person.crop.circle.badge.xmark' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/person.crop.circle.badge.xmark.png'> | person.crop.circle.badge.xmark | <img alt='person.crop.circle.fill.badge.xmark' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/person.crop.circle.fill.badge.xmark.png'> | person.crop.circle.fill.badge.xmark | <img alt='person.crop.circle.badge.exclam' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/person.crop.circle.badge.exclam.png'> | person.crop.circle.badge.exclam | <img alt='person.crop.circle.fill.badge.exclam' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/person.crop.circle.fill.badge.exclam.png'> | person.crop.circle.fill.badge.exclam |
|
||||
| <img alt='person.crop.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/person.crop.square.png'> | person.crop.square | <img alt='person.crop.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/person.crop.square.fill.png'> | person.crop.square.fill | <img alt='command' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/command.png'> | command | <img alt='option' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/option.png'> | option |
|
||||
| <img alt='alt' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/alt.png'> | alt | <img alt='delete.right' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/delete.right.png'> | delete.right | <img alt='delete.right.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/delete.right.fill.png'> | delete.right.fill | <img alt='clear' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/clear.png'> | clear |
|
||||
| <img alt='clear.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/clear.fill.png'> | clear.fill | <img alt='delete.left' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/delete.left.png'> | delete.left | <img alt='delete.left.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/delete.left.fill.png'> | delete.left.fill | <img alt='shift' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/shift.png'> | shift |
|
||||
| <img alt='shift.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/shift.fill.png'> | shift.fill | <img alt='capslock' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/capslock.png'> | capslock | <img alt='capslock.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/capslock.fill.png'> | capslock.fill | <img alt='escape' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/escape.png'> | escape |
|
||||
| <img alt='circle.bottomthird.split' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/circle.bottomthird.split.png'> | circle.bottomthird.split | <img alt='power' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/power.png'> | power | <img alt='globe' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/globe.png'> | globe | <img alt='sun.min' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/sun.min.png'> | sun.min |
|
||||
| <img alt='sun.min.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/sun.min.fill.png'> | sun.min.fill | <img alt='sun.max' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/sun.max.png'> | sun.max | <img alt='sun.max.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/sun.max.fill.png'> | sun.max.fill | <img alt='sunrise' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/sunrise.png'> | sunrise |
|
||||
| <img alt='sunrise.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/sunrise.fill.png'> | sunrise.fill | <img alt='sunset' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/sunset.png'> | sunset | <img alt='sunset.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/sunset.fill.png'> | sunset.fill | <img alt='sun.dust' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/sun.dust.png'> | sun.dust |
|
||||
| <img alt='sun.dust.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/sun.dust.fill.png'> | sun.dust.fill | <img alt='sun.haze' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/sun.haze.png'> | sun.haze | <img alt='sun.haze.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/sun.haze.fill.png'> | sun.haze.fill | <img alt='moon' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/moon.png'> | moon |
|
||||
| <img alt='moon.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/moon.fill.png'> | moon.fill | <img alt='moon.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/moon.circle.png'> | moon.circle | <img alt='moon.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/moon.circle.fill.png'> | moon.circle.fill | <img alt='zzz' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/zzz.png'> | zzz |
|
||||
| <img alt='moon.zzz' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/moon.zzz.png'> | moon.zzz | <img alt='moon.zzz.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/moon.zzz.fill.png'> | moon.zzz.fill | <img alt='sparkles' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/sparkles.png'> | sparkles | <img alt='moon.stars' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/moon.stars.png'> | moon.stars |
|
||||
| <img alt='moon.stars.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/moon.stars.fill.png'> | moon.stars.fill | <img alt='cloud' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cloud.png'> | cloud | <img alt='cloud.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cloud.fill.png'> | cloud.fill | <img alt='cloud.drizzle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cloud.drizzle.png'> | cloud.drizzle |
|
||||
| <img alt='cloud.drizzle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cloud.drizzle.fill.png'> | cloud.drizzle.fill | <img alt='cloud.rain' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cloud.rain.png'> | cloud.rain | <img alt='cloud.rain.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cloud.rain.fill.png'> | cloud.rain.fill | <img alt='cloud.heavyrain' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cloud.heavyrain.png'> | cloud.heavyrain |
|
||||
| <img alt='cloud.heavyrain.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cloud.heavyrain.fill.png'> | cloud.heavyrain.fill | <img alt='cloud.fog' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cloud.fog.png'> | cloud.fog | <img alt='cloud.fog.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cloud.fog.fill.png'> | cloud.fog.fill | <img alt='cloud.hail' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cloud.hail.png'> | cloud.hail |
|
||||
| <img alt='cloud.hail.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cloud.hail.fill.png'> | cloud.hail.fill | <img alt='cloud.snow' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cloud.snow.png'> | cloud.snow | <img alt='cloud.snow.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cloud.snow.fill.png'> | cloud.snow.fill | <img alt='cloud.sleet' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cloud.sleet.png'> | cloud.sleet |
|
||||
| <img alt='cloud.sleet.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cloud.sleet.fill.png'> | cloud.sleet.fill | <img alt='cloud.bolt' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cloud.bolt.png'> | cloud.bolt | <img alt='cloud.bolt.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cloud.bolt.fill.png'> | cloud.bolt.fill | <img alt='cloud.sun' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cloud.sun.png'> | cloud.sun |
|
||||
| <img alt='cloud.sun.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cloud.sun.fill.png'> | cloud.sun.fill | <img alt='cloud.sun.rain' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cloud.sun.rain.png'> | cloud.sun.rain | <img alt='cloud.sun.rain.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cloud.sun.rain.fill.png'> | cloud.sun.rain.fill | <img alt='cloud.sun.bolt' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cloud.sun.bolt.png'> | cloud.sun.bolt |
|
||||
| <img alt='cloud.sun.bolt.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cloud.sun.bolt.fill.png'> | cloud.sun.bolt.fill | <img alt='cloud.moon' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cloud.moon.png'> | cloud.moon | <img alt='cloud.moon.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cloud.moon.fill.png'> | cloud.moon.fill | <img alt='cloud.moon.rain' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cloud.moon.rain.png'> | cloud.moon.rain |
|
||||
| <img alt='cloud.moon.rain.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cloud.moon.rain.fill.png'> | cloud.moon.rain.fill | <img alt='cloud.bolt.rain' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cloud.bolt.rain.png'> | cloud.bolt.rain | <img alt='cloud.bolt.rain.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cloud.bolt.rain.fill.png'> | cloud.bolt.rain.fill | <img alt='cloud.moon.bolt' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cloud.moon.bolt.png'> | cloud.moon.bolt |
|
||||
| <img alt='cloud.moon.bolt.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cloud.moon.bolt.fill.png'> | cloud.moon.bolt.fill | <img alt='smoke' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/smoke.png'> | smoke | <img alt='smoke.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/smoke.fill.png'> | smoke.fill | <img alt='wind' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/wind.png'> | wind |
|
||||
| <img alt='snow' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/snow.png'> | snow | <img alt='wind.snow' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/wind.snow.png'> | wind.snow | <img alt='tornado' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/tornado.png'> | tornado | <img alt='tropicalstorm' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/tropicalstorm.png'> | tropicalstorm |
|
||||
| <img alt='hurricane' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/hurricane.png'> | hurricane | <img alt='thermometer.sun' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/thermometer.sun.png'> | thermometer.sun | <img alt='thermometer.snowflake' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/thermometer.snowflake.png'> | thermometer.snowflake | <img alt='thermometer' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/thermometer.png'> | thermometer |
|
||||
| <img alt='umbrella' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/umbrella.png'> | umbrella | <img alt='umbrella.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/umbrella.fill.png'> | umbrella.fill | <img alt='flame' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/flame.png'> | flame | <img alt='flame.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/flame.fill.png'> | flame.fill |
|
||||
| <img alt='light.min' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/light.min.png'> | light.min | <img alt='light.max' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/light.max.png'> | light.max | <img alt='rays' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rays.png'> | rays | <img alt='cursor.rays' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cursor.rays.png'> | cursor.rays |
|
||||
| <img alt='slowmo' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/slowmo.png'> | slowmo | <img alt='timelapse' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/timelapse.png'> | timelapse | <img alt='keyboard' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/keyboard.png'> | keyboard | <img alt='keyboard.chevron.compact.down' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/keyboard.chevron.compact.down.png'> | keyboard.chevron.compact.down |
|
||||
| <img alt='rectangle.3.offgrid' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.3.offgrid.png'> | rectangle.3.offgrid | <img alt='rectangle.3.offgrid.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.3.offgrid.fill.png'> | rectangle.3.offgrid.fill | <img alt='square.grid.3x2' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.grid.3x2.png'> | square.grid.3x2 | <img alt='square.grid.3x2.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.grid.3x2.fill.png'> | square.grid.3x2.fill |
|
||||
| <img alt='rectangle.grid.3x2' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.grid.3x2.png'> | rectangle.grid.3x2 | <img alt='rectangle.grid.3x2.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.grid.3x2.fill.png'> | rectangle.grid.3x2.fill | <img alt='square.grid.2x2' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.grid.2x2.png'> | square.grid.2x2 | <img alt='square.grid.2x2.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.grid.2x2.fill.png'> | square.grid.2x2.fill |
|
||||
| <img alt='rectangle.grid.2x2' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.grid.2x2.png'> | rectangle.grid.2x2 | <img alt='rectangle.grid.2x2.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.grid.2x2.fill.png'> | rectangle.grid.2x2.fill | <img alt='square.grid.4x3.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.grid.4x3.fill.png'> | square.grid.4x3.fill | <img alt='rectangle.grid.1x2' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.grid.1x2.png'> | rectangle.grid.1x2 |
|
||||
| <img alt='rectangle.grid.1x2.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.grid.1x2.fill.png'> | rectangle.grid.1x2.fill | <img alt='circle.grid.2x2' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/circle.grid.2x2.png'> | circle.grid.2x2 | <img alt='circle.grid.2x2.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/circle.grid.2x2.fill.png'> | circle.grid.2x2.fill | <img alt='circle.grid.3x3' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/circle.grid.3x3.png'> | circle.grid.3x3 |
|
||||
| <img alt='circle.grid.3x3.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/circle.grid.3x3.fill.png'> | circle.grid.3x3.fill | <img alt='circle.grid.hex' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/circle.grid.hex.png'> | circle.grid.hex | <img alt='circle.grid.hex.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/circle.grid.hex.fill.png'> | circle.grid.hex.fill | <img alt='checkmark.seal' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/checkmark.seal.png'> | checkmark.seal |
|
||||
| <img alt='checkmark.seal.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/checkmark.seal.fill.png'> | checkmark.seal.fill | <img alt='xmark.seal' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/xmark.seal.png'> | xmark.seal | <img alt='xmark.seal.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/xmark.seal.fill.png'> | xmark.seal.fill | <img alt='exclamationmark.triangle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/exclamationmark.triangle.png'> | exclamationmark.triangle |
|
||||
| <img alt='exclamationmark.triangle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/exclamationmark.triangle.fill.png'> | exclamationmark.triangle.fill | <img alt='drop.triangle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/drop.triangle.png'> | drop.triangle | <img alt='drop.triangle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/drop.triangle.fill.png'> | drop.triangle.fill | <img alt='play' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/play.png'> | play |
|
||||
| <img alt='play.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/play.fill.png'> | play.fill | <img alt='play.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/play.circle.png'> | play.circle | <img alt='play.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/play.circle.fill.png'> | play.circle.fill | <img alt='play.rectangle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/play.rectangle.png'> | play.rectangle |
|
||||
| <img alt='play.rectangle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/play.rectangle.fill.png'> | play.rectangle.fill | <img alt='pause' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/pause.png'> | pause | <img alt='pause.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/pause.fill.png'> | pause.fill | <img alt='pause.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/pause.circle.png'> | pause.circle |
|
||||
| <img alt='pause.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/pause.circle.fill.png'> | pause.circle.fill | <img alt='pause.rectangle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/pause.rectangle.png'> | pause.rectangle | <img alt='pause.rectangle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/pause.rectangle.fill.png'> | pause.rectangle.fill | <img alt='stop' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/stop.png'> | stop |
|
||||
| <img alt='stop.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/stop.fill.png'> | stop.fill | <img alt='stop.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/stop.circle.png'> | stop.circle | <img alt='stop.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/stop.circle.fill.png'> | stop.circle.fill | <img alt='playpause' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/playpause.png'> | playpause |
|
||||
| <img alt='playpause.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/playpause.fill.png'> | playpause.fill | <img alt='backward' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/backward.png'> | backward | <img alt='backward.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/backward.fill.png'> | backward.fill | <img alt='forward' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/forward.png'> | forward |
|
||||
| <img alt='forward.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/forward.fill.png'> | forward.fill | <img alt='backward.end' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/backward.end.png'> | backward.end | <img alt='backward.end.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/backward.end.fill.png'> | backward.end.fill | <img alt='forward.end' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/forward.end.png'> | forward.end |
|
||||
| <img alt='forward.end.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/forward.end.fill.png'> | forward.end.fill | <img alt='backward.end.alt' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/backward.end.alt.png'> | backward.end.alt | <img alt='backward.end.alt.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/backward.end.alt.fill.png'> | backward.end.alt.fill | <img alt='forward.end.alt' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/forward.end.alt.png'> | forward.end.alt |
|
||||
| <img alt='forward.end.alt.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/forward.end.alt.fill.png'> | forward.end.alt.fill | <img alt='eject' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/eject.png'> | eject | <img alt='eject.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/eject.fill.png'> | eject.fill | <img alt='memories' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/memories.png'> | memories |
|
||||
| <img alt='memories.badge.plus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/memories.badge.plus.png'> | memories.badge.plus | <img alt='memories.badge.minus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/memories.badge.minus.png'> | memories.badge.minus | <img alt='shuffle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/shuffle.png'> | shuffle | <img alt='repeat' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/repeat.png'> | repeat |
|
||||
| <img alt='repeat.1' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/repeat.1.png'> | repeat.1 | <img alt='speaker' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/speaker.png'> | speaker | <img alt='speaker.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/speaker.fill.png'> | speaker.fill | <img alt='speaker.slash' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/speaker.slash.png'> | speaker.slash |
|
||||
| <img alt='speaker.slash.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/speaker.slash.fill.png'> | speaker.slash.fill | <img alt='speaker.zzz' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/speaker.zzz.png'> | speaker.zzz | <img alt='speaker.zzz.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/speaker.zzz.fill.png'> | speaker.zzz.fill | <img alt='speaker.1' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/speaker.1.png'> | speaker.1 |
|
||||
| <img alt='speaker.1.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/speaker.1.fill.png'> | speaker.1.fill | <img alt='speaker.2' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/speaker.2.png'> | speaker.2 | <img alt='speaker.2.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/speaker.2.fill.png'> | speaker.2.fill | <img alt='speaker.3' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/speaker.3.png'> | speaker.3 |
|
||||
| <img alt='speaker.3.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/speaker.3.fill.png'> | speaker.3.fill | <img alt='badge.plus.radiowaves.right' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/badge.plus.radiowaves.right.png'> | badge.plus.radiowaves.right | <img alt='music.note' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/music.note.png'> | music.note | <img alt='music.mic' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/music.mic.png'> | music.mic |
|
||||
| <img alt='music.note.list' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/music.note.list.png'> | music.note.list | <img alt='goforward' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/goforward.png'> | goforward | <img alt='gobackward' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/gobackward.png'> | gobackward | <img alt='goforward.10' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/goforward.10.png'> | goforward.10 |
|
||||
| <img alt='gobackward.10' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/gobackward.10.png'> | gobackward.10 | <img alt='goforward.15' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/goforward.15.png'> | goforward.15 | <img alt='gobackward.15' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/gobackward.15.png'> | gobackward.15 | <img alt='goforward.30' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/goforward.30.png'> | goforward.30 |
|
||||
| <img alt='gobackward.30' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/gobackward.30.png'> | gobackward.30 | <img alt='goforward.45' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/goforward.45.png'> | goforward.45 | <img alt='gobackward.45' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/gobackward.45.png'> | gobackward.45 | <img alt='goforward.60' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/goforward.60.png'> | goforward.60 |
|
||||
| <img alt='gobackward.60' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/gobackward.60.png'> | gobackward.60 | <img alt='goforward.75' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/goforward.75.png'> | goforward.75 | <img alt='gobackward.75' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/gobackward.75.png'> | gobackward.75 | <img alt='goforward.90' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/goforward.90.png'> | goforward.90 |
|
||||
| <img alt='gobackward.90' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/gobackward.90.png'> | gobackward.90 | <img alt='goforward.10.ar' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/goforward.10.ar.png'> | goforward.10.ar | <img alt='gobackward.10.ar' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/gobackward.10.ar.png'> | gobackward.10.ar | <img alt='goforward.15.ar' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/goforward.15.ar.png'> | goforward.15.ar |
|
||||
| <img alt='gobackward.15.ar' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/gobackward.15.ar.png'> | gobackward.15.ar | <img alt='goforward.30.ar' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/goforward.30.ar.png'> | goforward.30.ar | <img alt='gobackward.30.ar' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/gobackward.30.ar.png'> | gobackward.30.ar | <img alt='goforward.45.ar' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/goforward.45.ar.png'> | goforward.45.ar |
|
||||
| <img alt='gobackward.45.ar' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/gobackward.45.ar.png'> | gobackward.45.ar | <img alt='goforward.60.ar' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/goforward.60.ar.png'> | goforward.60.ar | <img alt='gobackward.60.ar' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/gobackward.60.ar.png'> | gobackward.60.ar | <img alt='goforward.75.ar' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/goforward.75.ar.png'> | goforward.75.ar |
|
||||
| <img alt='gobackward.75.ar' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/gobackward.75.ar.png'> | gobackward.75.ar | <img alt='goforward.90.ar' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/goforward.90.ar.png'> | goforward.90.ar | <img alt='gobackward.90.ar' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/gobackward.90.ar.png'> | gobackward.90.ar | <img alt='goforward.10.hi' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/goforward.10.hi.png'> | goforward.10.hi |
|
||||
| <img alt='gobackward.10.hi' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/gobackward.10.hi.png'> | gobackward.10.hi | <img alt='goforward.15.hi' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/goforward.15.hi.png'> | goforward.15.hi | <img alt='gobackward.15.hi' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/gobackward.15.hi.png'> | gobackward.15.hi | <img alt='goforward.30.hi' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/goforward.30.hi.png'> | goforward.30.hi |
|
||||
| <img alt='gobackward.30.hi' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/gobackward.30.hi.png'> | gobackward.30.hi | <img alt='goforward.45.hi' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/goforward.45.hi.png'> | goforward.45.hi | <img alt='gobackward.45.hi' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/gobackward.45.hi.png'> | gobackward.45.hi | <img alt='goforward.60.hi' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/goforward.60.hi.png'> | goforward.60.hi |
|
||||
| <img alt='gobackward.60.hi' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/gobackward.60.hi.png'> | gobackward.60.hi | <img alt='goforward.75.hi' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/goforward.75.hi.png'> | goforward.75.hi | <img alt='gobackward.75.hi' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/gobackward.75.hi.png'> | gobackward.75.hi | <img alt='goforward.90.hi' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/goforward.90.hi.png'> | goforward.90.hi |
|
||||
| <img alt='gobackward.90.hi' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/gobackward.90.hi.png'> | gobackward.90.hi | <img alt='goforward.plus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/goforward.plus.png'> | goforward.plus | <img alt='gobackward.minus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/gobackward.minus.png'> | gobackward.minus | <img alt='magnifyingglass' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/magnifyingglass.png'> | magnifyingglass |
|
||||
| <img alt='magnifyingglass.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/magnifyingglass.circle.png'> | magnifyingglass.circle | <img alt='magnifyingglass.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/magnifyingglass.circle.fill.png'> | magnifyingglass.circle.fill | <img alt='plus.magnifyingglass' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/plus.magnifyingglass.png'> | plus.magnifyingglass | <img alt='minus.magnifyingglass' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/minus.magnifyingglass.png'> | minus.magnifyingglass |
|
||||
| <img alt='1.magnifyingglass' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/1.magnifyingglass.png'> | 1.magnifyingglass | <img alt='mic' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/mic.png'> | mic | <img alt='mic.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/mic.fill.png'> | mic.fill | <img alt='mic.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/mic.circle.png'> | mic.circle |
|
||||
| <img alt='mic.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/mic.circle.fill.png'> | mic.circle.fill | <img alt='mic.slash' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/mic.slash.png'> | mic.slash | <img alt='mic.slash.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/mic.slash.fill.png'> | mic.slash.fill | <img alt='suit.heart' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/suit.heart.png'> | suit.heart |
|
||||
| <img alt='suit.heart.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/suit.heart.fill.png'> | suit.heart.fill | <img alt='suit.club' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/suit.club.png'> | suit.club | <img alt='suit.club.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/suit.club.fill.png'> | suit.club.fill | <img alt='suit.spade' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/suit.spade.png'> | suit.spade |
|
||||
| <img alt='suit.spade.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/suit.spade.fill.png'> | suit.spade.fill | <img alt='suit.diamond' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/suit.diamond.png'> | suit.diamond | <img alt='suit.diamond.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/suit.diamond.fill.png'> | suit.diamond.fill | <img alt='heart' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/heart.png'> | heart |
|
||||
| <img alt='heart.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/heart.fill.png'> | heart.fill | <img alt='heart.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/heart.circle.png'> | heart.circle | <img alt='heart.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/heart.circle.fill.png'> | heart.circle.fill | <img alt='heart.slash' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/heart.slash.png'> | heart.slash |
|
||||
| <img alt='heart.slash.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/heart.slash.fill.png'> | heart.slash.fill | <img alt='heart.slash.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/heart.slash.circle.png'> | heart.slash.circle | <img alt='heart.slash.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/heart.slash.circle.fill.png'> | heart.slash.circle.fill | <img alt='rhombus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rhombus.png'> | rhombus |
|
||||
| <img alt='rhombus.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rhombus.fill.png'> | rhombus.fill | <img alt='star' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/star.png'> | star | <img alt='star.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/star.fill.png'> | star.fill | <img alt='star.lefthalf.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/star.lefthalf.fill.png'> | star.lefthalf.fill |
|
||||
| <img alt='star.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/star.circle.png'> | star.circle | <img alt='star.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/star.circle.fill.png'> | star.circle.fill | <img alt='star.slash' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/star.slash.png'> | star.slash | <img alt='star.slash.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/star.slash.fill.png'> | star.slash.fill |
|
||||
| <img alt='flag' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/flag.png'> | flag | <img alt='flag.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/flag.fill.png'> | flag.fill | <img alt='flag.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/flag.circle.png'> | flag.circle | <img alt='flag.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/flag.circle.fill.png'> | flag.circle.fill |
|
||||
| <img alt='flag.slash' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/flag.slash.png'> | flag.slash | <img alt='flag.slash.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/flag.slash.fill.png'> | flag.slash.fill | <img alt='location' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/location.png'> | location | <img alt='location.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/location.fill.png'> | location.fill |
|
||||
| <img alt='location.slash' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/location.slash.png'> | location.slash | <img alt='location.slash.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/location.slash.fill.png'> | location.slash.fill | <img alt='location.north' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/location.north.png'> | location.north | <img alt='location.north.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/location.north.fill.png'> | location.north.fill |
|
||||
| <img alt='location.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/location.circle.png'> | location.circle | <img alt='location.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/location.circle.fill.png'> | location.circle.fill | <img alt='location.north.line' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/location.north.line.png'> | location.north.line | <img alt='location.north.line.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/location.north.line.fill.png'> | location.north.line.fill |
|
||||
| <img alt='bell' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bell.png'> | bell | <img alt='bell.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bell.fill.png'> | bell.fill | <img alt='bell.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bell.circle.png'> | bell.circle | <img alt='bell.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bell.circle.fill.png'> | bell.circle.fill |
|
||||
| <img alt='bell.slash' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bell.slash.png'> | bell.slash | <img alt='bell.slash.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bell.slash.fill.png'> | bell.slash.fill | <img alt='tag' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/tag.png'> | tag | <img alt='tag.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/tag.fill.png'> | tag.fill |
|
||||
| <img alt='tag.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/tag.circle.png'> | tag.circle | <img alt='tag.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/tag.circle.fill.png'> | tag.circle.fill | <img alt='bolt' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bolt.png'> | bolt | <img alt='bolt.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bolt.fill.png'> | bolt.fill |
|
||||
| <img alt='bolt.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bolt.circle.png'> | bolt.circle | <img alt='bolt.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bolt.circle.fill.png'> | bolt.circle.fill | <img alt='bolt.slash' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bolt.slash.png'> | bolt.slash | <img alt='bolt.slash.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bolt.slash.fill.png'> | bolt.slash.fill |
|
||||
| <img alt='bolt.badge.a' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bolt.badge.a.png'> | bolt.badge.a | <img alt='bolt.badge.a.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bolt.badge.a.fill.png'> | bolt.badge.a.fill | <img alt='eye' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/eye.png'> | eye | <img alt='eye.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/eye.fill.png'> | eye.fill |
|
||||
| <img alt='eye.slash' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/eye.slash.png'> | eye.slash | <img alt='eye.slash.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/eye.slash.fill.png'> | eye.slash.fill | <img alt='icloud' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/icloud.png'> | icloud | <img alt='icloud.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/icloud.fill.png'> | icloud.fill |
|
||||
| <img alt='icloud.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/icloud.circle.png'> | icloud.circle | <img alt='icloud.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/icloud.circle.fill.png'> | icloud.circle.fill | <img alt='icloud.slash' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/icloud.slash.png'> | icloud.slash | <img alt='icloud.slash.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/icloud.slash.fill.png'> | icloud.slash.fill |
|
||||
| <img alt='exclamationmark.icloud' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/exclamationmark.icloud.png'> | exclamationmark.icloud | <img alt='exclamationmark.icloud.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/exclamationmark.icloud.fill.png'> | exclamationmark.icloud.fill | <img alt='xmark.icloud' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/xmark.icloud.png'> | xmark.icloud | <img alt='xmark.icloud.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/xmark.icloud.fill.png'> | xmark.icloud.fill |
|
||||
| <img alt='link.icloud' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/link.icloud.png'> | link.icloud | <img alt='link.icloud.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/link.icloud.fill.png'> | link.icloud.fill | <img alt='bolt.horizontal.icloud' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bolt.horizontal.icloud.png'> | bolt.horizontal.icloud | <img alt='bolt.horizontal.icloud.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bolt.horizontal.icloud.fill.png'> | bolt.horizontal.icloud.fill |
|
||||
| <img alt='person.icloud' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/person.icloud.png'> | person.icloud | <img alt='person.icloud.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/person.icloud.fill.png'> | person.icloud.fill | <img alt='lock.icloud' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/lock.icloud.png'> | lock.icloud | <img alt='lock.icloud.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/lock.icloud.fill.png'> | lock.icloud.fill |
|
||||
| <img alt='arrow.clockwise.icloud' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.clockwise.icloud.png'> | arrow.clockwise.icloud | <img alt='arrow.clockwise.icloud.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.clockwise.icloud.fill.png'> | arrow.clockwise.icloud.fill | <img alt='arrow.counterclockwise.icloud' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.counterclockwise.icloud.png'> | arrow.counterclockwise.icloud | <img alt='arrow.counterclockwise.icloud.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.counterclockwise.icloud.fill.png'> | arrow.counterclockwise.icloud.fill |
|
||||
| <img alt='icloud.and.arrow.down' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/icloud.and.arrow.down.png'> | icloud.and.arrow.down | <img alt='icloud.and.arrow.down.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/icloud.and.arrow.down.fill.png'> | icloud.and.arrow.down.fill | <img alt='icloud.and.arrow.up' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/icloud.and.arrow.up.png'> | icloud.and.arrow.up | <img alt='icloud.and.arrow.up.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/icloud.and.arrow.up.fill.png'> | icloud.and.arrow.up.fill |
|
||||
| <img alt='ant' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/ant.png'> | ant | <img alt='ant.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/ant.fill.png'> | ant.fill | <img alt='ant.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/ant.circle.png'> | ant.circle | <img alt='ant.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/ant.circle.fill.png'> | ant.circle.fill |
|
||||
| <img alt='flashlight.off.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/flashlight.off.fill.png'> | flashlight.off.fill | <img alt='flashlight.on.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/flashlight.on.fill.png'> | flashlight.on.fill | <img alt='camera' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/camera.png'> | camera | <img alt='camera.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/camera.fill.png'> | camera.fill |
|
||||
| <img alt='camera.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/camera.circle.png'> | camera.circle | <img alt='camera.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/camera.circle.fill.png'> | camera.circle.fill | <img alt='camera.rotate' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/camera.rotate.png'> | camera.rotate | <img alt='camera.rotate.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/camera.rotate.fill.png'> | camera.rotate.fill |
|
||||
| <img alt='camera.on.rectangle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/camera.on.rectangle.png'> | camera.on.rectangle | <img alt='camera.on.rectangle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/camera.on.rectangle.fill.png'> | camera.on.rectangle.fill | <img alt='message' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/message.png'> | message | <img alt='message.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/message.fill.png'> | message.fill |
|
||||
| <img alt='message.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/message.circle.png'> | message.circle | <img alt='message.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/message.circle.fill.png'> | message.circle.fill | <img alt='bubble.right' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bubble.right.png'> | bubble.right | <img alt='bubble.right.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bubble.right.fill.png'> | bubble.right.fill |
|
||||
| <img alt='bubble.left' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bubble.left.png'> | bubble.left | <img alt='bubble.left.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bubble.left.fill.png'> | bubble.left.fill | <img alt='exclamationmark.bubble' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/exclamationmark.bubble.png'> | exclamationmark.bubble | <img alt='exclamationmark.bubble.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/exclamationmark.bubble.fill.png'> | exclamationmark.bubble.fill |
|
||||
| <img alt='quote.bubble' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/quote.bubble.png'> | quote.bubble | <img alt='quote.bubble.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/quote.bubble.fill.png'> | quote.bubble.fill | <img alt='t.bubble' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/t.bubble.png'> | t.bubble | <img alt='t.bubble.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/t.bubble.fill.png'> | t.bubble.fill |
|
||||
| <img alt='text.bubble' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/text.bubble.png'> | text.bubble | <img alt='text.bubble.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/text.bubble.fill.png'> | text.bubble.fill | <img alt='captions.bubble' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/captions.bubble.png'> | captions.bubble | <img alt='captions.bubble.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/captions.bubble.fill.png'> | captions.bubble.fill |
|
||||
| <img alt='plus.bubble' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/plus.bubble.png'> | plus.bubble | <img alt='plus.bubble.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/plus.bubble.fill.png'> | plus.bubble.fill | <img alt='ellipses.bubble' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/ellipses.bubble.png'> | ellipses.bubble | <img alt='ellipses.bubble.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/ellipses.bubble.fill.png'> | ellipses.bubble.fill |
|
||||
| <img alt='bubble.middle.bottom' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bubble.middle.bottom.png'> | bubble.middle.bottom | <img alt='bubble.middle.bottom.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bubble.middle.bottom.fill.png'> | bubble.middle.bottom.fill | <img alt='bubble.middle.top' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bubble.middle.top.png'> | bubble.middle.top | <img alt='bubble.middle.top.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bubble.middle.top.fill.png'> | bubble.middle.top.fill |
|
||||
| <img alt='bubble.left.and.bubble.right' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bubble.left.and.bubble.right.png'> | bubble.left.and.bubble.right | <img alt='bubble.left.and.bubble.right.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bubble.left.and.bubble.right.fill.png'> | bubble.left.and.bubble.right.fill | <img alt='phone' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/phone.png'> | phone | <img alt='phone.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/phone.fill.png'> | phone.fill |
|
||||
| <img alt='phone.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/phone.circle.png'> | phone.circle | <img alt='phone.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/phone.circle.fill.png'> | phone.circle.fill | <img alt='phone.badge.plus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/phone.badge.plus.png'> | phone.badge.plus | <img alt='phone.fill.badge.plus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/phone.fill.badge.plus.png'> | phone.fill.badge.plus |
|
||||
| <img alt='phone.arrow.up.right' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/phone.arrow.up.right.png'> | phone.arrow.up.right | <img alt='phone.fill.arrow.up.right' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/phone.fill.arrow.up.right.png'> | phone.fill.arrow.up.right | <img alt='phone.arrow.down.left' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/phone.arrow.down.left.png'> | phone.arrow.down.left | <img alt='phone.fill.arrow.down.left' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/phone.fill.arrow.down.left.png'> | phone.fill.arrow.down.left |
|
||||
| <img alt='phone.arrow.right' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/phone.arrow.right.png'> | phone.arrow.right | <img alt='phone.fill.arrow.right' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/phone.fill.arrow.right.png'> | phone.fill.arrow.right | <img alt='phone.down' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/phone.down.png'> | phone.down | <img alt='phone.down.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/phone.down.fill.png'> | phone.down.fill |
|
||||
| <img alt='phone.down.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/phone.down.circle.png'> | phone.down.circle | <img alt='phone.down.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/phone.down.circle.fill.png'> | phone.down.circle.fill | <img alt='teletype' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/teletype.png'> | teletype | <img alt='teletype.answer' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/teletype.answer.png'> | teletype.answer |
|
||||
| <img alt='video' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/video.png'> | video | <img alt='video.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/video.fill.png'> | video.fill | <img alt='video.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/video.circle.png'> | video.circle | <img alt='video.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/video.circle.fill.png'> | video.circle.fill |
|
||||
| <img alt='video.slash' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/video.slash.png'> | video.slash | <img alt='video.slash.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/video.slash.fill.png'> | video.slash.fill | <img alt='video.badge.plus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/video.badge.plus.png'> | video.badge.plus | <img alt='video.badge.plus.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/video.badge.plus.fill.png'> | video.badge.plus.fill |
|
||||
| <img alt='arrow.up.right.video' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.right.video.png'> | arrow.up.right.video | <img alt='arrow.up.right.video.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.right.video.fill.png'> | arrow.up.right.video.fill | <img alt='arrow.down.left.video' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.down.left.video.png'> | arrow.down.left.video | <img alt='arrow.down.left.video.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.down.left.video.fill.png'> | arrow.down.left.video.fill |
|
||||
| <img alt='questionmark.video' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/questionmark.video.png'> | questionmark.video | <img alt='questionmark.video.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/questionmark.video.fill.png'> | questionmark.video.fill | <img alt='envelope' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/envelope.png'> | envelope | <img alt='envelope.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/envelope.fill.png'> | envelope.fill |
|
||||
| <img alt='envelope.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/envelope.circle.png'> | envelope.circle | <img alt='envelope.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/envelope.circle.fill.png'> | envelope.circle.fill | <img alt='envelope.open' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/envelope.open.png'> | envelope.open | <img alt='envelope.open.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/envelope.open.fill.png'> | envelope.open.fill |
|
||||
| <img alt='envelope.badge' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/envelope.badge.png'> | envelope.badge | <img alt='envelope.badge.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/envelope.badge.fill.png'> | envelope.badge.fill | <img alt='gear' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/gear.png'> | gear | <img alt='signature' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/signature.png'> | signature |
|
||||
| <img alt='scissors' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/scissors.png'> | scissors | <img alt='scissors.badge.ellipsis' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/scissors.badge.ellipsis.png'> | scissors.badge.ellipsis | <img alt='ellipsis' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/ellipsis.png'> | ellipsis | <img alt='ellipsis.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/ellipsis.circle.png'> | ellipsis.circle |
|
||||
| <img alt='ellipsis.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/ellipsis.circle.fill.png'> | ellipsis.circle.fill | <img alt='bag' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bag.png'> | bag | <img alt='bag.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bag.fill.png'> | bag.fill | <img alt='bag.badge.plus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bag.badge.plus.png'> | bag.badge.plus |
|
||||
| <img alt='bag.fill.badge.plus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bag.fill.badge.plus.png'> | bag.fill.badge.plus | <img alt='bag.badge.minus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bag.badge.minus.png'> | bag.badge.minus | <img alt='bag.fill.badge.minus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bag.fill.badge.minus.png'> | bag.fill.badge.minus | <img alt='cart' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cart.png'> | cart |
|
||||
| <img alt='cart.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cart.fill.png'> | cart.fill | <img alt='cart.badge.plus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cart.badge.plus.png'> | cart.badge.plus | <img alt='cart.fill.badge.plus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cart.fill.badge.plus.png'> | cart.fill.badge.plus | <img alt='cart.badge.minus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cart.badge.minus.png'> | cart.badge.minus |
|
||||
| <img alt='cart.fill.badge.minus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cart.fill.badge.minus.png'> | cart.fill.badge.minus | <img alt='creditcard' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/creditcard.png'> | creditcard | <img alt='creditcard.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/creditcard.fill.png'> | creditcard.fill | <img alt='wand.and.rays' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/wand.and.rays.png'> | wand.and.rays |
|
||||
| <img alt='wand.and.rays.inverse' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/wand.and.rays.inverse.png'> | wand.and.rays.inverse | <img alt='wand.and.stars' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/wand.and.stars.png'> | wand.and.stars | <img alt='wand.and.stars.inverse' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/wand.and.stars.inverse.png'> | wand.and.stars.inverse | <img alt='crop' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/crop.png'> | crop |
|
||||
| <img alt='crop.rotate' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/crop.rotate.png'> | crop.rotate | <img alt='dial' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/dial.png'> | dial | <img alt='dial.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/dial.fill.png'> | dial.fill | <img alt='nosign' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/nosign.png'> | nosign |
|
||||
| <img alt='gauge' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/gauge.png'> | gauge | <img alt='gauge.badge.plus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/gauge.badge.plus.png'> | gauge.badge.plus | <img alt='gauge.badge.minus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/gauge.badge.minus.png'> | gauge.badge.minus | <img alt='speedometer' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/speedometer.png'> | speedometer |
|
||||
| <img alt='metronome' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/metronome.png'> | metronome | <img alt='hifispeaker' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/hifispeaker.png'> | hifispeaker | <img alt='hifispeaker.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/hifispeaker.fill.png'> | hifispeaker.fill | <img alt='tuningfork' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/tuningfork.png'> | tuningfork |
|
||||
| <img alt='paintbrush' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/paintbrush.png'> | paintbrush | <img alt='paintbrush.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/paintbrush.fill.png'> | paintbrush.fill | <img alt='bandage' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bandage.png'> | bandage | <img alt='bandage.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bandage.fill.png'> | bandage.fill |
|
||||
| <img alt='wrench' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/wrench.png'> | wrench | <img alt='wrench.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/wrench.fill.png'> | wrench.fill | <img alt='hammer' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/hammer.png'> | hammer | <img alt='hammer.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/hammer.fill.png'> | hammer.fill |
|
||||
| <img alt='eyedropper' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/eyedropper.png'> | eyedropper | <img alt='eyedropper.halffull' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/eyedropper.halffull.png'> | eyedropper.halffull | <img alt='eyedropper.full' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/eyedropper.full.png'> | eyedropper.full | <img alt='printer' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/printer.png'> | printer |
|
||||
| <img alt='printer.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/printer.fill.png'> | printer.fill | <img alt='briefcase' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/briefcase.png'> | briefcase | <img alt='briefcase.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/briefcase.fill.png'> | briefcase.fill | <img alt='house' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/house.png'> | house |
|
||||
| <img alt='house.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/house.fill.png'> | house.fill | <img alt='music.house' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/music.house.png'> | music.house | <img alt='music.house.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/music.house.fill.png'> | music.house.fill | <img alt='lock' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/lock.png'> | lock |
|
||||
| <img alt='lock.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/lock.fill.png'> | lock.fill | <img alt='lock.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/lock.circle.png'> | lock.circle | <img alt='lock.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/lock.circle.fill.png'> | lock.circle.fill | <img alt='lock.slash' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/lock.slash.png'> | lock.slash |
|
||||
| <img alt='lock.slash.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/lock.slash.fill.png'> | lock.slash.fill | <img alt='lock.open' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/lock.open.png'> | lock.open | <img alt='lock.open.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/lock.open.fill.png'> | lock.open.fill | <img alt='lock.rotation' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/lock.rotation.png'> | lock.rotation |
|
||||
| <img alt='lock.rotation.open' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/lock.rotation.open.png'> | lock.rotation.open | <img alt='wifi' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/wifi.png'> | wifi | <img alt='wifi.slash' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/wifi.slash.png'> | wifi.slash | <img alt='wifi.exclamationmark' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/wifi.exclamationmark.png'> | wifi.exclamationmark |
|
||||
| <img alt='pin' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/pin.png'> | pin | <img alt='pin.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/pin.fill.png'> | pin.fill | <img alt='pin.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/pin.circle.png'> | pin.circle | <img alt='pin.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/pin.circle.fill.png'> | pin.circle.fill |
|
||||
| <img alt='pin.slash' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/pin.slash.png'> | pin.slash | <img alt='pin.slash.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/pin.slash.fill.png'> | pin.slash.fill | <img alt='mappin' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/mappin.png'> | mappin | <img alt='mappin.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/mappin.circle.png'> | mappin.circle |
|
||||
| <img alt='mappin.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/mappin.circle.fill.png'> | mappin.circle.fill | <img alt='mappin.slash' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/mappin.slash.png'> | mappin.slash | <img alt='mappin.and.ellipse' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/mappin.and.ellipse.png'> | mappin.and.ellipse | <img alt='map' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/map.png'> | map |
|
||||
| <img alt='map.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/map.fill.png'> | map.fill | <img alt='safari' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/safari.png'> | safari | <img alt='safari.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/safari.fill.png'> | safari.fill | <img alt='rotate.left' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rotate.left.png'> | rotate.left |
|
||||
| <img alt='rotate.left.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rotate.left.fill.png'> | rotate.left.fill | <img alt='rotate.right' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rotate.right.png'> | rotate.right | <img alt='rotate.right.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rotate.right.fill.png'> | rotate.right.fill | <img alt='selection.pin.in.out' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/selection.pin.in.out.png'> | selection.pin.in.out |
|
||||
| <img alt='tv' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/tv.png'> | tv | <img alt='tv.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/tv.fill.png'> | tv.fill | <img alt='tv.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/tv.circle.png'> | tv.circle | <img alt='tv.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/tv.circle.fill.png'> | tv.circle.fill |
|
||||
| <img alt='tv.music.note' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/tv.music.note.png'> | tv.music.note | <img alt='tv.music.note.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/tv.music.note.fill.png'> | tv.music.note.fill | <img alt='desktopcomputer' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/desktopcomputer.png'> | desktopcomputer | <img alt='airplayvideo' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/airplayvideo.png'> | airplayvideo |
|
||||
| <img alt='airplayaudio' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/airplayaudio.png'> | airplayaudio | <img alt='dot.radiowaves.left.and.right' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/dot.radiowaves.left.and.right.png'> | dot.radiowaves.left.and.right | <img alt='dot.radiowaves.right' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/dot.radiowaves.right.png'> | dot.radiowaves.right | <img alt='radiowaves.left' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/radiowaves.left.png'> | radiowaves.left |
|
||||
| <img alt='radiowaves.right' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/radiowaves.right.png'> | radiowaves.right | <img alt='antenna.radiowaves.left.and.right' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/antenna.radiowaves.left.and.right.png'> | antenna.radiowaves.left.and.right | <img alt='guitars' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/guitars.png'> | guitars | <img alt='car' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/car.png'> | car |
|
||||
| <img alt='car.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/car.fill.png'> | car.fill | <img alt='tram.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/tram.fill.png'> | tram.fill | <img alt='bed.double' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bed.double.png'> | bed.double | <img alt='bed.double.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bed.double.fill.png'> | bed.double.fill |
|
||||
| <img alt='hare' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/hare.png'> | hare | <img alt='hare.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/hare.fill.png'> | hare.fill | <img alt='tortoise' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/tortoise.png'> | tortoise | <img alt='tortoise.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/tortoise.fill.png'> | tortoise.fill |
|
||||
| <img alt='film' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/film.png'> | film | <img alt='film.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/film.fill.png'> | film.fill | <img alt='sportscourt' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/sportscourt.png'> | sportscourt | <img alt='sportscourt.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/sportscourt.fill.png'> | sportscourt.fill |
|
||||
| <img alt='smiley' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/smiley.png'> | smiley | <img alt='smiley.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/smiley.fill.png'> | smiley.fill | <img alt='qrcode' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/qrcode.png'> | qrcode | <img alt='barcode' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/barcode.png'> | barcode |
|
||||
| <img alt='viewfinder' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/viewfinder.png'> | viewfinder | <img alt='viewfinder.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/viewfinder.circle.png'> | viewfinder.circle | <img alt='viewfinder.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/viewfinder.circle.fill.png'> | viewfinder.circle.fill | <img alt='barcode.viewfinder' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/barcode.viewfinder.png'> | barcode.viewfinder |
|
||||
| <img alt='qrcode.viewfinder' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/qrcode.viewfinder.png'> | qrcode.viewfinder | <img alt='camera.viewfinder' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/camera.viewfinder.png'> | camera.viewfinder | <img alt='faceid' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/faceid.png'> | faceid | <img alt='doc.text.viewfinder' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/doc.text.viewfinder.png'> | doc.text.viewfinder |
|
||||
| <img alt='rectangle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.png'> | rectangle | <img alt='rectangle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.fill.png'> | rectangle.fill | <img alt='photo' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/photo.png'> | photo | <img alt='photo.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/photo.fill.png'> | photo.fill |
|
||||
| <img alt='plus.rectangle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/plus.rectangle.png'> | plus.rectangle | <img alt='plus.rectangle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/plus.rectangle.fill.png'> | plus.rectangle.fill | <img alt='minus.rectangle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/minus.rectangle.png'> | minus.rectangle | <img alt='minus.rectangle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/minus.rectangle.fill.png'> | minus.rectangle.fill |
|
||||
| <img alt='checkmark.rectangle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/checkmark.rectangle.png'> | checkmark.rectangle | <img alt='checkmark.rectangle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/checkmark.rectangle.fill.png'> | checkmark.rectangle.fill | <img alt='xmark.rectangle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/xmark.rectangle.png'> | xmark.rectangle | <img alt='xmark.rectangle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/xmark.rectangle.fill.png'> | xmark.rectangle.fill |
|
||||
| <img alt='person.crop.rectangle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/person.crop.rectangle.png'> | person.crop.rectangle | <img alt='person.crop.rectangle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/person.crop.rectangle.fill.png'> | person.crop.rectangle.fill | <img alt='rectangle.badge.checkmark' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.badge.checkmark.png'> | rectangle.badge.checkmark | <img alt='rectangle.fill.badge.checkmark' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.fill.badge.checkmark.png'> | rectangle.fill.badge.checkmark |
|
||||
| <img alt='rectangle.badge.xmark' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.badge.xmark.png'> | rectangle.badge.xmark | <img alt='rectangle.fill.badge.xmark' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.fill.badge.xmark.png'> | rectangle.fill.badge.xmark | <img alt='sidebar.left' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/sidebar.left.png'> | sidebar.left | <img alt='sidebar.right' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/sidebar.right.png'> | sidebar.right |
|
||||
| <img alt='macwindow' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/macwindow.png'> | macwindow | <img alt='uiwindow.split.2x1' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/uiwindow.split.2x1.png'> | uiwindow.split.2x1 | <img alt='rectangle.dock' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.dock.png'> | rectangle.dock | <img alt='rectangle.split.3x1' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.split.3x1.png'> | rectangle.split.3x1 |
|
||||
| <img alt='rectangle.split.3x1.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.split.3x1.fill.png'> | rectangle.split.3x1.fill | <img alt='square.split.2x1' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.split.2x1.png'> | square.split.2x1 | <img alt='square.split.2x1.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.split.2x1.fill.png'> | square.split.2x1.fill | <img alt='square.split.1x2' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.split.1x2.png'> | square.split.1x2 |
|
||||
| <img alt='square.split.1x2.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.split.1x2.fill.png'> | square.split.1x2.fill | <img alt='square.split.2x2' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.split.2x2.png'> | square.split.2x2 | <img alt='square.split.2x2.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.split.2x2.fill.png'> | square.split.2x2.fill | <img alt='dot.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/dot.square.png'> | dot.square |
|
||||
| <img alt='dot.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/dot.square.fill.png'> | dot.square.fill | <img alt='squares.below.rectangle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/squares.below.rectangle.png'> | squares.below.rectangle | <img alt='rectangle.split.3x3' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.split.3x3.png'> | rectangle.split.3x3 | <img alt='rectangle.split.3x3.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.split.3x3.fill.png'> | rectangle.split.3x3.fill |
|
||||
| <img alt='table' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/table.png'> | table | <img alt='table.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/table.fill.png'> | table.fill | <img alt='table.badge.more' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/table.badge.more.png'> | table.badge.more | <img alt='table.badge.more.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/table.badge.more.fill.png'> | table.badge.more.fill |
|
||||
| <img alt='rectangle.on.rectangle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.on.rectangle.png'> | rectangle.on.rectangle | <img alt='rectangle.fill.on.rectangle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.fill.on.rectangle.fill.png'> | rectangle.fill.on.rectangle.fill | <img alt='plus.rectangle.on.rectangle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/plus.rectangle.on.rectangle.png'> | plus.rectangle.on.rectangle | <img alt='plus.rectangle.fill.on.rectangle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/plus.rectangle.fill.on.rectangle.fill.png'> | plus.rectangle.fill.on.rectangle.fill |
|
||||
| <img alt='photo.on.rectangle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/photo.on.rectangle.png'> | photo.on.rectangle | <img alt='photo.fill.on.rectangle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/photo.fill.on.rectangle.fill.png'> | photo.fill.on.rectangle.fill | <img alt='rectangle.on.rectangle.angled' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.on.rectangle.angled.png'> | rectangle.on.rectangle.angled | <img alt='rectangle.fill.on.rectangle.angled.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.fill.on.rectangle.angled.fill.png'> | rectangle.fill.on.rectangle.angled.fill |
|
||||
| <img alt='rectangle.stack' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.stack.png'> | rectangle.stack | <img alt='rectangle.stack.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.stack.fill.png'> | rectangle.stack.fill | <img alt='rectangle.stack.badge.plus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.stack.badge.plus.png'> | rectangle.stack.badge.plus | <img alt='rectangle.stack.fill.badge.plus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.stack.fill.badge.plus.png'> | rectangle.stack.fill.badge.plus |
|
||||
| <img alt='rectangle.stack.badge.minus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.stack.badge.minus.png'> | rectangle.stack.badge.minus | <img alt='rectangle.stack.fill.badge.minus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.stack.fill.badge.minus.png'> | rectangle.stack.fill.badge.minus | <img alt='rectangle.stack.badge.person.crop' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.stack.badge.person.crop.png'> | rectangle.stack.badge.person.crop | <img alt='rectangle.stack.fill.badge.person.crop' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.stack.fill.badge.person.crop.png'> | rectangle.stack.fill.badge.person.crop |
|
||||
| <img alt='rectangle.stack.person.crop' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.stack.person.crop.png'> | rectangle.stack.person.crop | <img alt='rectangle.stack.person.crop.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.stack.person.crop.fill.png'> | rectangle.stack.person.crop.fill | <img alt='person.2.square.stack' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/person.2.square.stack.png'> | person.2.square.stack | <img alt='person.2.square.stack.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/person.2.square.stack.fill.png'> | person.2.square.stack.fill |
|
||||
| <img alt='square.on.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.on.square.png'> | square.on.square | <img alt='square.fill.on.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.fill.on.square.fill.png'> | square.fill.on.square.fill | <img alt='plus.square.on.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/plus.square.on.square.png'> | plus.square.on.square | <img alt='plus.square.fill.on.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/plus.square.fill.on.square.fill.png'> | plus.square.fill.on.square.fill |
|
||||
| <img alt='square.on.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.on.circle.png'> | square.on.circle | <img alt='square.fill.on.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.fill.on.circle.fill.png'> | square.fill.on.circle.fill | <img alt='square.stack' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.stack.png'> | square.stack | <img alt='square.stack.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.stack.fill.png'> | square.stack.fill |
|
||||
| <img alt='pano' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/pano.png'> | pano | <img alt='pano.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/pano.fill.png'> | pano.fill | <img alt='square.and.line.vertical.and.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.and.line.vertical.and.square.png'> | square.and.line.vertical.and.square | <img alt='square.fill.and.line.vertical.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.fill.and.line.vertical.square.fill.png'> | square.fill.and.line.vertical.square.fill |
|
||||
| <img alt='square.fill.and.line.vertical.and.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.fill.and.line.vertical.and.square.png'> | square.fill.and.line.vertical.and.square | <img alt='square.and.line.vertical.and.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.and.line.vertical.and.square.fill.png'> | square.and.line.vertical.and.square.fill | <img alt='flowchart' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/flowchart.png'> | flowchart | <img alt='flowchart.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/flowchart.fill.png'> | flowchart.fill |
|
||||
| <img alt='shield' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/shield.png'> | shield | <img alt='shield.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/shield.fill.png'> | shield.fill | <img alt='shield.slash' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/shield.slash.png'> | shield.slash | <img alt='shield.slash.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/shield.slash.fill.png'> | shield.slash.fill |
|
||||
| <img alt='lock.shield' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/lock.shield.png'> | lock.shield | <img alt='lock.shield.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/lock.shield.fill.png'> | lock.shield.fill | <img alt='checkmark.shield' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/checkmark.shield.png'> | checkmark.shield | <img alt='checkmark.shield.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/checkmark.shield.fill.png'> | checkmark.shield.fill |
|
||||
| <img alt='xmark.shield' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/xmark.shield.png'> | xmark.shield | <img alt='xmark.shield.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/xmark.shield.fill.png'> | xmark.shield.fill | <img alt='exclamationmark.shield' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/exclamationmark.shield.png'> | exclamationmark.shield | <img alt='exclamationmark.shield.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/exclamationmark.shield.fill.png'> | exclamationmark.shield.fill |
|
||||
| <img alt='shield.lefthalf.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/shield.lefthalf.fill.png'> | shield.lefthalf.fill | <img alt='slider.horizontal.below.rectangle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/slider.horizontal.below.rectangle.png'> | slider.horizontal.below.rectangle | <img alt='hexagon' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/hexagon.png'> | hexagon | <img alt='hexagon.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/hexagon.fill.png'> | hexagon.fill |
|
||||
| <img alt='cube' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cube.png'> | cube | <img alt='cube.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cube.fill.png'> | cube.fill | <img alt='cube.box' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cube.box.png'> | cube.box | <img alt='cube.box.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cube.box.fill.png'> | cube.box.fill |
|
||||
| <img alt='arkit' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arkit.png'> | arkit | <img alt='square.stack.3d.down.right' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.stack.3d.down.right.png'> | square.stack.3d.down.right | <img alt='square.stack.3d.down.right.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.stack.3d.down.right.fill.png'> | square.stack.3d.down.right.fill | <img alt='square.stack.3d.up' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.stack.3d.up.png'> | square.stack.3d.up |
|
||||
| <img alt='square.stack.3d.up.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.stack.3d.up.fill.png'> | square.stack.3d.up.fill | <img alt='square.stack.3d.up.slash' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.stack.3d.up.slash.png'> | square.stack.3d.up.slash | <img alt='square.stack.3d.up.slash.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.stack.3d.up.slash.fill.png'> | square.stack.3d.up.slash.fill | <img alt='square.stack.3d.down.dottedline' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.stack.3d.down.dottedline.png'> | square.stack.3d.down.dottedline |
|
||||
| <img alt='livephoto' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/livephoto.png'> | livephoto | <img alt='livephoto.slash' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/livephoto.slash.png'> | livephoto.slash | <img alt='livephoto.play' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/livephoto.play.png'> | livephoto.play | <img alt='scope' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/scope.png'> | scope |
|
||||
| <img alt='helm' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/helm.png'> | helm | <img alt='clock' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/clock.png'> | clock | <img alt='clock.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/clock.fill.png'> | clock.fill | <img alt='alarm' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/alarm.png'> | alarm |
|
||||
| <img alt='alarm.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/alarm.fill.png'> | alarm.fill | <img alt='stopwatch' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/stopwatch.png'> | stopwatch | <img alt='stopwatch.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/stopwatch.fill.png'> | stopwatch.fill | <img alt='timer' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/timer.png'> | timer |
|
||||
| <img alt='gamecontroller' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/gamecontroller.png'> | gamecontroller | <img alt='gamecontroller.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/gamecontroller.fill.png'> | gamecontroller.fill | <img alt='ear' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/ear.png'> | ear | <img alt='hand.raised' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/hand.raised.png'> | hand.raised |
|
||||
| <img alt='hand.raised.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/hand.raised.fill.png'> | hand.raised.fill | <img alt='hand.raised.slash' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/hand.raised.slash.png'> | hand.raised.slash | <img alt='hand.raised.slash.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/hand.raised.slash.fill.png'> | hand.raised.slash.fill | <img alt='hand.thumbsup' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/hand.thumbsup.png'> | hand.thumbsup |
|
||||
| <img alt='hand.thumbsup.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/hand.thumbsup.fill.png'> | hand.thumbsup.fill | <img alt='hand.thumbsdown' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/hand.thumbsdown.png'> | hand.thumbsdown | <img alt='hand.thumbsdown.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/hand.thumbsdown.fill.png'> | hand.thumbsdown.fill | <img alt='hand.draw' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/hand.draw.png'> | hand.draw |
|
||||
| <img alt='hand.draw.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/hand.draw.fill.png'> | hand.draw.fill | <img alt='hand.point.left' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/hand.point.left.png'> | hand.point.left | <img alt='hand.point.left.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/hand.point.left.fill.png'> | hand.point.left.fill | <img alt='hand.point.right' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/hand.point.right.png'> | hand.point.right |
|
||||
| <img alt='hand.point.right.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/hand.point.right.fill.png'> | hand.point.right.fill | <img alt='rectangle.compress.vertical' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.compress.vertical.png'> | rectangle.compress.vertical | <img alt='rectangle.expand.vertical' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.expand.vertical.png'> | rectangle.expand.vertical | <img alt='rectangle.and.arrow.up.right.and.arrow.down.left' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.and.arrow.up.right.and.arrow.down.left.png'> | rectangle.and.arrow.up.right.and.arrow.down.left |
|
||||
| <img alt='rectangle.and.arrow.up.right.and.arrow.down.left.slash' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rectangle.and.arrow.up.right.and.arrow.down.left.slash.png'> | rectangle.and.arrow.up.right.and.arrow.down.left.slash | <img alt='chart.bar' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/chart.bar.png'> | chart.bar | <img alt='chart.bar.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/chart.bar.fill.png'> | chart.bar.fill | <img alt='chart.pie' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/chart.pie.png'> | chart.pie |
|
||||
| <img alt='chart.pie.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/chart.pie.fill.png'> | chart.pie.fill | <img alt='burst' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/burst.png'> | burst | <img alt='burst.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/burst.fill.png'> | burst.fill | <img alt='waveform.path.ecg' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/waveform.path.ecg.png'> | waveform.path.ecg |
|
||||
| <img alt='waveform.path' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/waveform.path.png'> | waveform.path | <img alt='waveform.path.badge.plus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/waveform.path.badge.plus.png'> | waveform.path.badge.plus | <img alt='waveform.path.badge.minus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/waveform.path.badge.minus.png'> | waveform.path.badge.minus | <img alt='waveform' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/waveform.png'> | waveform |
|
||||
| <img alt='waveform.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/waveform.circle.png'> | waveform.circle | <img alt='waveform.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/waveform.circle.fill.png'> | waveform.circle.fill | <img alt='staroflife' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/staroflife.png'> | staroflife | <img alt='staroflife.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/staroflife.fill.png'> | staroflife.fill |
|
||||
| <img alt='headphones' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/headphones.png'> | headphones | <img alt='gift' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/gift.png'> | gift | <img alt='gift.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/gift.fill.png'> | gift.fill | <img alt='app' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/app.png'> | app |
|
||||
| <img alt='app.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/app.fill.png'> | app.fill | <img alt='plus.app' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/plus.app.png'> | plus.app | <img alt='plus.app.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/plus.app.fill.png'> | plus.app.fill | <img alt='app.badge' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/app.badge.png'> | app.badge |
|
||||
| <img alt='app.badge.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/app.badge.fill.png'> | app.badge.fill | <img alt='app.gift' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/app.gift.png'> | app.gift | <img alt='app.gift.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/app.gift.fill.png'> | app.gift.fill | <img alt='airplane' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/airplane.png'> | airplane |
|
||||
| <img alt='studentdesk' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/studentdesk.png'> | studentdesk | <img alt='hourglass' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/hourglass.png'> | hourglass | <img alt='hourglass.bottomhalf.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/hourglass.bottomhalf.fill.png'> | hourglass.bottomhalf.fill | <img alt='hourglass.tophalf.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/hourglass.tophalf.fill.png'> | hourglass.tophalf.fill |
|
||||
| <img alt='paragraph' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/paragraph.png'> | paragraph | <img alt='purchased' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/purchased.png'> | purchased | <img alt='purchased.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/purchased.circle.png'> | purchased.circle | <img alt='purchased.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/purchased.circle.fill.png'> | purchased.circle.fill |
|
||||
| <img alt='exclamationmark.octagon' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/exclamationmark.octagon.png'> | exclamationmark.octagon | <img alt='exclamationmark.octagon.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/exclamationmark.octagon.fill.png'> | exclamationmark.octagon.fill | <img alt='xmark.octagon' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/xmark.octagon.png'> | xmark.octagon | <img alt='xmark.octagon.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/xmark.octagon.fill.png'> | xmark.octagon.fill |
|
||||
| <img alt='bolt.horizontal' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bolt.horizontal.png'> | bolt.horizontal | <img alt='bolt.horizontal.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bolt.horizontal.fill.png'> | bolt.horizontal.fill | <img alt='bolt.horizontal.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bolt.horizontal.circle.png'> | bolt.horizontal.circle | <img alt='bolt.horizontal.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bolt.horizontal.circle.fill.png'> | bolt.horizontal.circle.fill |
|
||||
| <img alt='perspective' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/perspective.png'> | perspective | <img alt='aspectratio' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/aspectratio.png'> | aspectratio | <img alt='aspectratio.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/aspectratio.fill.png'> | aspectratio.fill | <img alt='skew' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/skew.png'> | skew |
|
||||
| <img alt='flip.horizontal' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/flip.horizontal.png'> | flip.horizontal | <img alt='flip.horizontal.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/flip.horizontal.fill.png'> | flip.horizontal.fill | <img alt='grid' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/grid.png'> | grid | <img alt='grid.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/grid.circle.png'> | grid.circle |
|
||||
| <img alt='grid.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/grid.circle.fill.png'> | grid.circle.fill | <img alt='burn' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/burn.png'> | burn | <img alt='scribble' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/scribble.png'> | scribble | <img alt='lasso' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/lasso.png'> | lasso |
|
||||
| <img alt='recordingtape' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/recordingtape.png'> | recordingtape | <img alt='eyeglasses' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/eyeglasses.png'> | eyeglasses | <img alt='battery.100' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/battery.100.png'> | battery.100 | <img alt='battery.25' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/battery.25.png'> | battery.25 |
|
||||
| <img alt='battery.0' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/battery.0.png'> | battery.0 | <img alt='lightbulb' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/lightbulb.png'> | lightbulb | <img alt='lightbulb.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/lightbulb.fill.png'> | lightbulb.fill | <img alt='lightbulb.slash' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/lightbulb.slash.png'> | lightbulb.slash |
|
||||
| <img alt='lightbulb.slash.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/lightbulb.slash.fill.png'> | lightbulb.slash.fill | <img alt='list.dash' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/list.dash.png'> | list.dash | <img alt='list.bullet' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/list.bullet.png'> | list.bullet | <img alt='list.bullet.indent' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/list.bullet.indent.png'> | list.bullet.indent |
|
||||
| <img alt='list.number' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/list.number.png'> | list.number | <img alt='increase.indent' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/increase.indent.png'> | increase.indent | <img alt='decrease.indent' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/decrease.indent.png'> | decrease.indent | <img alt='decrease.quotelevel' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/decrease.quotelevel.png'> | decrease.quotelevel |
|
||||
| <img alt='increase.quotelevel' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/increase.quotelevel.png'> | increase.quotelevel | <img alt='list.bullet.below.rectangle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/list.bullet.below.rectangle.png'> | list.bullet.below.rectangle | <img alt='text.badge.plus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/text.badge.plus.png'> | text.badge.plus | <img alt='text.badge.minus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/text.badge.minus.png'> | text.badge.minus |
|
||||
| <img alt='text.badge.checkmark' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/text.badge.checkmark.png'> | text.badge.checkmark | <img alt='text.badge.xmark' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/text.badge.xmark.png'> | text.badge.xmark | <img alt='text.badge.star' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/text.badge.star.png'> | text.badge.star | <img alt='text.insert' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/text.insert.png'> | text.insert |
|
||||
| <img alt='text.append' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/text.append.png'> | text.append | <img alt='text.quote' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/text.quote.png'> | text.quote | <img alt='text.alignleft' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/text.alignleft.png'> | text.alignleft | <img alt='text.aligncenter' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/text.aligncenter.png'> | text.aligncenter |
|
||||
| <img alt='text.alignright' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/text.alignright.png'> | text.alignright | <img alt='text.justify' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/text.justify.png'> | text.justify | <img alt='text.justifyleft' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/text.justifyleft.png'> | text.justifyleft | <img alt='text.justifyright' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/text.justifyright.png'> | text.justifyright |
|
||||
| <img alt='slider.horizontal.3' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/slider.horizontal.3.png'> | slider.horizontal.3 | <img alt='line.horizontal.3' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/line.horizontal.3.png'> | line.horizontal.3 | <img alt='line.horizontal.3.decrease' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/line.horizontal.3.decrease.png'> | line.horizontal.3.decrease | <img alt='line.horizontal.3.decrease.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/line.horizontal.3.decrease.circle.png'> | line.horizontal.3.decrease.circle |
|
||||
| <img alt='line.horizontal.3.decrease.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/line.horizontal.3.decrease.circle.fill.png'> | line.horizontal.3.decrease.circle.fill | <img alt='a' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/a.png'> | a | <img alt='textformat.size' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/textformat.size.png'> | textformat.size | <img alt='textformat.alt' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/textformat.alt.png'> | textformat.alt |
|
||||
| <img alt='textformat' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/textformat.png'> | textformat | <img alt='textformat.subscript' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/textformat.subscript.png'> | textformat.subscript | <img alt='textformat.superscript' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/textformat.superscript.png'> | textformat.superscript | <img alt='bold' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bold.png'> | bold |
|
||||
| <img alt='italic' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/italic.png'> | italic | <img alt='underline' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/underline.png'> | underline | <img alt='strikethrough' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/strikethrough.png'> | strikethrough | <img alt='bold.italic.underline' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bold.italic.underline.png'> | bold.italic.underline |
|
||||
| <img alt='bold.underline' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bold.underline.png'> | bold.underline | <img alt='view.2d' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/view.2d.png'> | view.2d | <img alt='view.3d' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/view.3d.png'> | view.3d | <img alt='text.cursor' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/text.cursor.png'> | text.cursor |
|
||||
| <img alt='fx' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/fx.png'> | fx | <img alt='f.cursive' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/f.cursive.png'> | f.cursive | <img alt='f.cursive.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/f.cursive.circle.png'> | f.cursive.circle | <img alt='f.cursive.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/f.cursive.circle.fill.png'> | f.cursive.circle.fill |
|
||||
| <img alt='sum' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/sum.png'> | sum | <img alt='percent' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/percent.png'> | percent | <img alt='function' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/function.png'> | function | <img alt='textformat.abc' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/textformat.abc.png'> | textformat.abc |
|
||||
| <img alt='textformat.abc.dottedunderline' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/textformat.abc.dottedunderline.png'> | textformat.abc.dottedunderline | <img alt='textformat.123' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/textformat.123.png'> | textformat.123 | <img alt='info' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/info.png'> | info | <img alt='info.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/info.circle.png'> | info.circle |
|
||||
| <img alt='info.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/info.circle.fill.png'> | info.circle.fill | <img alt='textbox' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/textbox.png'> | textbox | <img alt='at' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/at.png'> | at | <img alt='at.badge.plus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/at.badge.plus.png'> | at.badge.plus |
|
||||
| <img alt='at.badge.minus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/at.badge.minus.png'> | at.badge.minus | <img alt='questionmark' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/questionmark.png'> | questionmark | <img alt='questionmark.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/questionmark.circle.png'> | questionmark.circle | <img alt='questionmark.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/questionmark.circle.fill.png'> | questionmark.circle.fill |
|
||||
| <img alt='questionmark.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/questionmark.square.png'> | questionmark.square | <img alt='questionmark.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/questionmark.square.fill.png'> | questionmark.square.fill | <img alt='questionmark.diamond' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/questionmark.diamond.png'> | questionmark.diamond | <img alt='questionmark.diamond.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/questionmark.diamond.fill.png'> | questionmark.diamond.fill |
|
||||
| <img alt='exclamationmark' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/exclamationmark.png'> | exclamationmark | <img alt='exclamationmark.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/exclamationmark.circle.png'> | exclamationmark.circle | <img alt='exclamationmark.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/exclamationmark.circle.fill.png'> | exclamationmark.circle.fill | <img alt='exclamationmark.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/exclamationmark.square.png'> | exclamationmark.square |
|
||||
| <img alt='exclamationmark.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/exclamationmark.square.fill.png'> | exclamationmark.square.fill | <img alt='plus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/plus.png'> | plus | <img alt='plus.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/plus.circle.png'> | plus.circle | <img alt='plus.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/plus.circle.fill.png'> | plus.circle.fill |
|
||||
| <img alt='plus.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/plus.square.png'> | plus.square | <img alt='plus.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/plus.square.fill.png'> | plus.square.fill | <img alt='minus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/minus.png'> | minus | <img alt='minus.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/minus.circle.png'> | minus.circle |
|
||||
| <img alt='minus.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/minus.circle.fill.png'> | minus.circle.fill | <img alt='minus.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/minus.square.png'> | minus.square | <img alt='minus.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/minus.square.fill.png'> | minus.square.fill | <img alt='plusminus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/plusminus.png'> | plusminus |
|
||||
| <img alt='plusminus.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/plusminus.circle.png'> | plusminus.circle | <img alt='plusminus.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/plusminus.circle.fill.png'> | plusminus.circle.fill | <img alt='plus.slash.minus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/plus.slash.minus.png'> | plus.slash.minus | <img alt='minus.slash.plus' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/minus.slash.plus.png'> | minus.slash.plus |
|
||||
| <img alt='multiply' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/multiply.png'> | multiply | <img alt='multiply.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/multiply.circle.png'> | multiply.circle | <img alt='multiply.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/multiply.circle.fill.png'> | multiply.circle.fill | <img alt='multiply.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/multiply.square.png'> | multiply.square |
|
||||
| <img alt='multiply.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/multiply.square.fill.png'> | multiply.square.fill | <img alt='divide' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/divide.png'> | divide | <img alt='divide.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/divide.circle.png'> | divide.circle | <img alt='divide.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/divide.circle.fill.png'> | divide.circle.fill |
|
||||
| <img alt='divide.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/divide.square.png'> | divide.square | <img alt='divide.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/divide.square.fill.png'> | divide.square.fill | <img alt='equal' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/equal.png'> | equal | <img alt='equal.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/equal.circle.png'> | equal.circle |
|
||||
| <img alt='equal.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/equal.circle.fill.png'> | equal.circle.fill | <img alt='equal.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/equal.square.png'> | equal.square | <img alt='equal.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/equal.square.fill.png'> | equal.square.fill | <img alt='lessthan' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/lessthan.png'> | lessthan |
|
||||
| <img alt='lessthan.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/lessthan.circle.png'> | lessthan.circle | <img alt='lessthan.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/lessthan.circle.fill.png'> | lessthan.circle.fill | <img alt='lessthan.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/lessthan.square.png'> | lessthan.square | <img alt='lessthan.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/lessthan.square.fill.png'> | lessthan.square.fill |
|
||||
| <img alt='greaterthan' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/greaterthan.png'> | greaterthan | <img alt='greaterthan.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/greaterthan.circle.png'> | greaterthan.circle | <img alt='greaterthan.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/greaterthan.circle.fill.png'> | greaterthan.circle.fill | <img alt='greaterthan.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/greaterthan.square.png'> | greaterthan.square |
|
||||
| <img alt='greaterthan.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/greaterthan.square.fill.png'> | greaterthan.square.fill | <img alt='chevron.left.slash.chevron.right' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/chevron.left.slash.chevron.right.png'> | chevron.left.slash.chevron.right | <img alt='number' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/number.png'> | number | <img alt='number.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/number.circle.png'> | number.circle |
|
||||
| <img alt='number.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/number.circle.fill.png'> | number.circle.fill | <img alt='number.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/number.square.png'> | number.square | <img alt='number.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/number.square.fill.png'> | number.square.fill | <img alt='x.squareroot' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/x.squareroot.png'> | x.squareroot |
|
||||
| <img alt='xmark' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/xmark.png'> | xmark | <img alt='xmark.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/xmark.circle.png'> | xmark.circle | <img alt='xmark.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/xmark.circle.fill.png'> | xmark.circle.fill | <img alt='xmark.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/xmark.square.png'> | xmark.square |
|
||||
| <img alt='xmark.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/xmark.square.fill.png'> | xmark.square.fill | <img alt='checkmark' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/checkmark.png'> | checkmark | <img alt='checkmark.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/checkmark.circle.png'> | checkmark.circle | <img alt='checkmark.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/checkmark.circle.fill.png'> | checkmark.circle.fill |
|
||||
| <img alt='checkmark.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/checkmark.square.png'> | checkmark.square | <img alt='checkmark.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/checkmark.square.fill.png'> | checkmark.square.fill | <img alt='chevron.up' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/chevron.up.png'> | chevron.up | <img alt='chevron.up.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/chevron.up.circle.png'> | chevron.up.circle |
|
||||
| <img alt='chevron.up.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/chevron.up.circle.fill.png'> | chevron.up.circle.fill | <img alt='chevron.up.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/chevron.up.square.png'> | chevron.up.square | <img alt='chevron.up.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/chevron.up.square.fill.png'> | chevron.up.square.fill | <img alt='chevron.down' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/chevron.down.png'> | chevron.down |
|
||||
| <img alt='chevron.down.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/chevron.down.circle.png'> | chevron.down.circle | <img alt='chevron.down.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/chevron.down.circle.fill.png'> | chevron.down.circle.fill | <img alt='chevron.down.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/chevron.down.square.png'> | chevron.down.square | <img alt='chevron.down.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/chevron.down.square.fill.png'> | chevron.down.square.fill |
|
||||
| <img alt='chevron.left' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/chevron.left.png'> | chevron.left | <img alt='chevron.left.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/chevron.left.circle.png'> | chevron.left.circle | <img alt='chevron.left.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/chevron.left.circle.fill.png'> | chevron.left.circle.fill | <img alt='chevron.left.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/chevron.left.square.png'> | chevron.left.square |
|
||||
| <img alt='chevron.left.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/chevron.left.square.fill.png'> | chevron.left.square.fill | <img alt='chevron.right' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/chevron.right.png'> | chevron.right | <img alt='chevron.right.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/chevron.right.circle.png'> | chevron.right.circle | <img alt='chevron.right.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/chevron.right.circle.fill.png'> | chevron.right.circle.fill |
|
||||
| <img alt='chevron.right.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/chevron.right.square.png'> | chevron.right.square | <img alt='chevron.right.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/chevron.right.square.fill.png'> | chevron.right.square.fill | <img alt='chevron.left.2' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/chevron.left.2.png'> | chevron.left.2 | <img alt='chevron.right.2' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/chevron.right.2.png'> | chevron.right.2 |
|
||||
| <img alt='control' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/control.png'> | control | <img alt='projective' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/projective.png'> | projective | <img alt='chevron.up.chevron.down' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/chevron.up.chevron.down.png'> | chevron.up.chevron.down | <img alt='chevron.compact.up' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/chevron.compact.up.png'> | chevron.compact.up |
|
||||
| <img alt='chevron.compact.down' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/chevron.compact.down.png'> | chevron.compact.down | <img alt='chevron.compact.left' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/chevron.compact.left.png'> | chevron.compact.left | <img alt='chevron.compact.right' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/chevron.compact.right.png'> | chevron.compact.right | <img alt='arrow.up' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.png'> | arrow.up |
|
||||
| <img alt='arrow.up.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.circle.png'> | arrow.up.circle | <img alt='arrow.up.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.circle.fill.png'> | arrow.up.circle.fill | <img alt='arrow.up.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.square.png'> | arrow.up.square | <img alt='arrow.up.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.square.fill.png'> | arrow.up.square.fill |
|
||||
| <img alt='arrow.down' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.down.png'> | arrow.down | <img alt='arrow.down.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.down.circle.png'> | arrow.down.circle | <img alt='arrow.down.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.down.circle.fill.png'> | arrow.down.circle.fill | <img alt='arrow.down.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.down.square.png'> | arrow.down.square |
|
||||
| <img alt='arrow.down.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.down.square.fill.png'> | arrow.down.square.fill | <img alt='arrow.left' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.left.png'> | arrow.left | <img alt='arrow.left.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.left.circle.png'> | arrow.left.circle | <img alt='arrow.left.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.left.circle.fill.png'> | arrow.left.circle.fill |
|
||||
| <img alt='arrow.left.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.left.square.png'> | arrow.left.square | <img alt='arrow.left.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.left.square.fill.png'> | arrow.left.square.fill | <img alt='arrow.right' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.right.png'> | arrow.right | <img alt='arrow.right.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.right.circle.png'> | arrow.right.circle |
|
||||
| <img alt='arrow.right.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.right.circle.fill.png'> | arrow.right.circle.fill | <img alt='arrow.right.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.right.square.png'> | arrow.right.square | <img alt='arrow.right.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.right.square.fill.png'> | arrow.right.square.fill | <img alt='arrow.up.left' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.left.png'> | arrow.up.left |
|
||||
| <img alt='arrow.up.left.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.left.circle.png'> | arrow.up.left.circle | <img alt='arrow.up.left.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.left.circle.fill.png'> | arrow.up.left.circle.fill | <img alt='arrow.up.left.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.left.square.png'> | arrow.up.left.square | <img alt='arrow.up.left.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.left.square.fill.png'> | arrow.up.left.square.fill |
|
||||
| <img alt='arrow.up.right' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.right.png'> | arrow.up.right | <img alt='arrow.up.right.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.right.circle.png'> | arrow.up.right.circle | <img alt='arrow.up.right.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.right.circle.fill.png'> | arrow.up.right.circle.fill | <img alt='arrow.up.right.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.right.square.png'> | arrow.up.right.square |
|
||||
| <img alt='arrow.up.right.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.right.square.fill.png'> | arrow.up.right.square.fill | <img alt='arrow.down.left' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.down.left.png'> | arrow.down.left | <img alt='arrow.down.left.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.down.left.circle.png'> | arrow.down.left.circle | <img alt='arrow.down.left.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.down.left.circle.fill.png'> | arrow.down.left.circle.fill |
|
||||
| <img alt='arrow.down.left.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.down.left.square.png'> | arrow.down.left.square | <img alt='arrow.down.left.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.down.left.square.fill.png'> | arrow.down.left.square.fill | <img alt='arrow.down.right' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.down.right.png'> | arrow.down.right | <img alt='arrow.down.right.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.down.right.circle.png'> | arrow.down.right.circle |
|
||||
| <img alt='arrow.down.right.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.down.right.circle.fill.png'> | arrow.down.right.circle.fill | <img alt='arrow.down.right.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.down.right.square.png'> | arrow.down.right.square | <img alt='arrow.down.right.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.down.right.square.fill.png'> | arrow.down.right.square.fill | <img alt='arrow.up.arrow.down' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.arrow.down.png'> | arrow.up.arrow.down |
|
||||
| <img alt='arrow.up.arrow.down.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.arrow.down.circle.png'> | arrow.up.arrow.down.circle | <img alt='arrow.up.arrow.down.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.arrow.down.circle.fill.png'> | arrow.up.arrow.down.circle.fill | <img alt='arrow.up.arrow.down.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.arrow.down.square.png'> | arrow.up.arrow.down.square | <img alt='arrow.up.arrow.down.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.arrow.down.square.fill.png'> | arrow.up.arrow.down.square.fill |
|
||||
| <img alt='arrow.right.arrow.left' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.right.arrow.left.png'> | arrow.right.arrow.left | <img alt='arrow.right.arrow.left.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.right.arrow.left.circle.png'> | arrow.right.arrow.left.circle | <img alt='arrow.right.arrow.left.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.right.arrow.left.circle.fill.png'> | arrow.right.arrow.left.circle.fill | <img alt='arrow.right.arrow.left.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.right.arrow.left.square.png'> | arrow.right.arrow.left.square |
|
||||
| <img alt='arrow.right.arrow.left.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.right.arrow.left.square.fill.png'> | arrow.right.arrow.left.square.fill | <img alt='arrow.turn.right.up' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.turn.right.up.png'> | arrow.turn.right.up | <img alt='arrow.turn.right.down' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.turn.right.down.png'> | arrow.turn.right.down | <img alt='arrow.turn.down.left' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.turn.down.left.png'> | arrow.turn.down.left |
|
||||
| <img alt='arrow.turn.down.right' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.turn.down.right.png'> | arrow.turn.down.right | <img alt='arrow.turn.left.up' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.turn.left.up.png'> | arrow.turn.left.up | <img alt='arrow.turn.left.down' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.turn.left.down.png'> | arrow.turn.left.down | <img alt='arrow.turn.up.left' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.turn.up.left.png'> | arrow.turn.up.left |
|
||||
| <img alt='arrow.turn.up.right' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.turn.up.right.png'> | arrow.turn.up.right | <img alt='arrow.uturn.up' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.uturn.up.png'> | arrow.uturn.up | <img alt='arrow.uturn.up.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.uturn.up.circle.png'> | arrow.uturn.up.circle | <img alt='arrow.uturn.up.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.uturn.up.circle.fill.png'> | arrow.uturn.up.circle.fill |
|
||||
| <img alt='arrow.uturn.up.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.uturn.up.square.png'> | arrow.uturn.up.square | <img alt='arrow.uturn.up.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.uturn.up.square.fill.png'> | arrow.uturn.up.square.fill | <img alt='arrow.uturn.down' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.uturn.down.png'> | arrow.uturn.down | <img alt='arrow.uturn.down.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.uturn.down.circle.png'> | arrow.uturn.down.circle |
|
||||
| <img alt='arrow.uturn.down.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.uturn.down.circle.fill.png'> | arrow.uturn.down.circle.fill | <img alt='arrow.uturn.down.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.uturn.down.square.png'> | arrow.uturn.down.square | <img alt='arrow.uturn.down.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.uturn.down.square.fill.png'> | arrow.uturn.down.square.fill | <img alt='arrow.uturn.left' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.uturn.left.png'> | arrow.uturn.left |
|
||||
| <img alt='arrow.uturn.left.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.uturn.left.circle.png'> | arrow.uturn.left.circle | <img alt='arrow.uturn.left.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.uturn.left.circle.fill.png'> | arrow.uturn.left.circle.fill | <img alt='arrow.uturn.left.circle.badge.ellipsis' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.uturn.left.circle.badge.ellipsis.png'> | arrow.uturn.left.circle.badge.ellipsis | <img alt='arrow.uturn.left.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.uturn.left.square.png'> | arrow.uturn.left.square |
|
||||
| <img alt='arrow.uturn.left.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.uturn.left.square.fill.png'> | arrow.uturn.left.square.fill | <img alt='arrow.uturn.right' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.uturn.right.png'> | arrow.uturn.right | <img alt='arrow.uturn.right.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.uturn.right.circle.png'> | arrow.uturn.right.circle | <img alt='arrow.uturn.right.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.uturn.right.circle.fill.png'> | arrow.uturn.right.circle.fill |
|
||||
| <img alt='arrow.uturn.right.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.uturn.right.square.png'> | arrow.uturn.right.square | <img alt='arrow.uturn.right.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.uturn.right.square.fill.png'> | arrow.uturn.right.square.fill | <img alt='arrow.up.and.down' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.and.down.png'> | arrow.up.and.down | <img alt='arrow.up.and.down.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.and.down.circle.png'> | arrow.up.and.down.circle |
|
||||
| <img alt='arrow.up.and.down.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.and.down.circle.fill.png'> | arrow.up.and.down.circle.fill | <img alt='arrow.up.and.down.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.and.down.square.png'> | arrow.up.and.down.square | <img alt='arrow.up.and.down.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.and.down.square.fill.png'> | arrow.up.and.down.square.fill | <img alt='arrow.left.and.right' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.left.and.right.png'> | arrow.left.and.right |
|
||||
| <img alt='arrow.left.and.right.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.left.and.right.circle.png'> | arrow.left.and.right.circle | <img alt='arrow.left.and.right.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.left.and.right.circle.fill.png'> | arrow.left.and.right.circle.fill | <img alt='arrow.left.and.right.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.left.and.right.square.png'> | arrow.left.and.right.square | <img alt='arrow.left.and.right.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.left.and.right.square.fill.png'> | arrow.left.and.right.square.fill |
|
||||
| <img alt='arrow.up.to.line.alt' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.to.line.alt.png'> | arrow.up.to.line.alt | <img alt='arrow.up.to.line' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.to.line.png'> | arrow.up.to.line | <img alt='arrow.down.to.line.alt' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.down.to.line.alt.png'> | arrow.down.to.line.alt | <img alt='arrow.down.to.line' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.down.to.line.png'> | arrow.down.to.line |
|
||||
| <img alt='arrow.left.to.line.alt' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.left.to.line.alt.png'> | arrow.left.to.line.alt | <img alt='arrow.left.to.line' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.left.to.line.png'> | arrow.left.to.line | <img alt='arrow.right.to.line.alt' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.right.to.line.alt.png'> | arrow.right.to.line.alt | <img alt='arrow.right.to.line' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.right.to.line.png'> | arrow.right.to.line |
|
||||
| <img alt='return' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/return.png'> | return | <img alt='arrow.clockwise' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.clockwise.png'> | arrow.clockwise | <img alt='arrow.clockwise.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.clockwise.circle.png'> | arrow.clockwise.circle | <img alt='arrow.clockwise.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.clockwise.circle.fill.png'> | arrow.clockwise.circle.fill |
|
||||
| <img alt='arrow.counterclockwise' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.counterclockwise.png'> | arrow.counterclockwise | <img alt='arrow.counterclockwise.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.counterclockwise.circle.png'> | arrow.counterclockwise.circle | <img alt='arrow.counterclockwise.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.counterclockwise.circle.fill.png'> | arrow.counterclockwise.circle.fill | <img alt='arrow.up.left.and.arrow.down.right' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.left.and.arrow.down.right.png'> | arrow.up.left.and.arrow.down.right |
|
||||
| <img alt='arrow.down.right.and.arrow.up.left' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.down.right.and.arrow.up.left.png'> | arrow.down.right.and.arrow.up.left | <img alt='arrow.2.squarepath' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.2.squarepath.png'> | arrow.2.squarepath | <img alt='arrow.2.circlepath' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.2.circlepath.png'> | arrow.2.circlepath | <img alt='arrow.2.circlepath.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.2.circlepath.circle.png'> | arrow.2.circlepath.circle |
|
||||
| <img alt='arrow.2.circlepath.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.2.circlepath.circle.fill.png'> | arrow.2.circlepath.circle.fill | <img alt='arrow.3.trianglepath' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.3.trianglepath.png'> | arrow.3.trianglepath | <img alt='leaf.arrow.circlepath' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/leaf.arrow.circlepath.png'> | leaf.arrow.circlepath | <img alt='arrow.up.right.diamond' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.right.diamond.png'> | arrow.up.right.diamond |
|
||||
| <img alt='arrow.up.right.diamond.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.up.right.diamond.fill.png'> | arrow.up.right.diamond.fill | <img alt='arrow.merge' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.merge.png'> | arrow.merge | <img alt='arrow.swap' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.swap.png'> | arrow.swap | <img alt='arrow.branch' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrow.branch.png'> | arrow.branch |
|
||||
| <img alt='arrowtriangle.up' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrowtriangle.up.png'> | arrowtriangle.up | <img alt='arrowtriangle.up.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrowtriangle.up.fill.png'> | arrowtriangle.up.fill | <img alt='arrowtriangle.up.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrowtriangle.up.circle.png'> | arrowtriangle.up.circle | <img alt='arrowtriangle.up.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrowtriangle.up.circle.fill.png'> | arrowtriangle.up.circle.fill |
|
||||
| <img alt='arrowtriangle.up.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrowtriangle.up.square.png'> | arrowtriangle.up.square | <img alt='arrowtriangle.up.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrowtriangle.up.square.fill.png'> | arrowtriangle.up.square.fill | <img alt='arrowtriangle.down' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrowtriangle.down.png'> | arrowtriangle.down | <img alt='arrowtriangle.down.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrowtriangle.down.fill.png'> | arrowtriangle.down.fill |
|
||||
| <img alt='arrowtriangle.down.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrowtriangle.down.circle.png'> | arrowtriangle.down.circle | <img alt='arrowtriangle.down.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrowtriangle.down.circle.fill.png'> | arrowtriangle.down.circle.fill | <img alt='arrowtriangle.down.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrowtriangle.down.square.png'> | arrowtriangle.down.square | <img alt='arrowtriangle.down.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrowtriangle.down.square.fill.png'> | arrowtriangle.down.square.fill |
|
||||
| <img alt='arrowtriangle.left' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrowtriangle.left.png'> | arrowtriangle.left | <img alt='arrowtriangle.left.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrowtriangle.left.fill.png'> | arrowtriangle.left.fill | <img alt='arrowtriangle.left.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrowtriangle.left.circle.png'> | arrowtriangle.left.circle | <img alt='arrowtriangle.left.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrowtriangle.left.circle.fill.png'> | arrowtriangle.left.circle.fill |
|
||||
| <img alt='arrowtriangle.left.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrowtriangle.left.square.png'> | arrowtriangle.left.square | <img alt='arrowtriangle.left.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrowtriangle.left.square.fill.png'> | arrowtriangle.left.square.fill | <img alt='arrowtriangle.right' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrowtriangle.right.png'> | arrowtriangle.right | <img alt='arrowtriangle.right.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrowtriangle.right.fill.png'> | arrowtriangle.right.fill |
|
||||
| <img alt='arrowtriangle.right.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrowtriangle.right.circle.png'> | arrowtriangle.right.circle | <img alt='arrowtriangle.right.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrowtriangle.right.circle.fill.png'> | arrowtriangle.right.circle.fill | <img alt='arrowtriangle.right.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrowtriangle.right.square.png'> | arrowtriangle.right.square | <img alt='arrowtriangle.right.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/arrowtriangle.right.square.fill.png'> | arrowtriangle.right.square.fill |
|
||||
| <img alt='triangle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/triangle.png'> | triangle | <img alt='triangle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/triangle.fill.png'> | triangle.fill | <img alt='triangle.lefthalf.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/triangle.lefthalf.fill.png'> | triangle.lefthalf.fill | <img alt='triangle.righthalf.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/triangle.righthalf.fill.png'> | triangle.righthalf.fill |
|
||||
| <img alt='capsule' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/capsule.png'> | capsule | <img alt='capsule.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/capsule.fill.png'> | capsule.fill | <img alt='circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/circle.png'> | circle | <img alt='circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/circle.fill.png'> | circle.fill |
|
||||
| <img alt='circle.lefthalf.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/circle.lefthalf.fill.png'> | circle.lefthalf.fill | <img alt='circle.righthalf.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/circle.righthalf.fill.png'> | circle.righthalf.fill | <img alt='largecircle.fill.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/largecircle.fill.circle.png'> | largecircle.fill.circle | <img alt='smallcircle.fill.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/smallcircle.fill.circle.png'> | smallcircle.fill.circle |
|
||||
| <img alt='smallcircle.fill.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/smallcircle.fill.circle.fill.png'> | smallcircle.fill.circle.fill | <img alt='smallcircle.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/smallcircle.circle.png'> | smallcircle.circle | <img alt='smallcircle.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/smallcircle.circle.fill.png'> | smallcircle.circle.fill | <img alt='slash.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/slash.circle.png'> | slash.circle |
|
||||
| <img alt='slash.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/slash.circle.fill.png'> | slash.circle.fill | <img alt='asterisk.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/asterisk.circle.png'> | asterisk.circle | <img alt='asterisk.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/asterisk.circle.fill.png'> | asterisk.circle.fill | <img alt='a.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/a.circle.png'> | a.circle |
|
||||
| <img alt='a.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/a.circle.fill.png'> | a.circle.fill | <img alt='b.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/b.circle.png'> | b.circle | <img alt='b.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/b.circle.fill.png'> | b.circle.fill | <img alt='c.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/c.circle.png'> | c.circle |
|
||||
| <img alt='c.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/c.circle.fill.png'> | c.circle.fill | <img alt='d.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/d.circle.png'> | d.circle | <img alt='d.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/d.circle.fill.png'> | d.circle.fill | <img alt='e.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/e.circle.png'> | e.circle |
|
||||
| <img alt='e.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/e.circle.fill.png'> | e.circle.fill | <img alt='f.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/f.circle.png'> | f.circle | <img alt='f.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/f.circle.fill.png'> | f.circle.fill | <img alt='g.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/g.circle.png'> | g.circle |
|
||||
| <img alt='g.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/g.circle.fill.png'> | g.circle.fill | <img alt='h.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/h.circle.png'> | h.circle | <img alt='h.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/h.circle.fill.png'> | h.circle.fill | <img alt='i.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/i.circle.png'> | i.circle |
|
||||
| <img alt='i.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/i.circle.fill.png'> | i.circle.fill | <img alt='j.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/j.circle.png'> | j.circle | <img alt='j.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/j.circle.fill.png'> | j.circle.fill | <img alt='k.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/k.circle.png'> | k.circle |
|
||||
| <img alt='k.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/k.circle.fill.png'> | k.circle.fill | <img alt='l.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/l.circle.png'> | l.circle | <img alt='l.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/l.circle.fill.png'> | l.circle.fill | <img alt='m.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/m.circle.png'> | m.circle |
|
||||
| <img alt='m.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/m.circle.fill.png'> | m.circle.fill | <img alt='n.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/n.circle.png'> | n.circle | <img alt='n.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/n.circle.fill.png'> | n.circle.fill | <img alt='o.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/o.circle.png'> | o.circle |
|
||||
| <img alt='o.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/o.circle.fill.png'> | o.circle.fill | <img alt='p.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/p.circle.png'> | p.circle | <img alt='p.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/p.circle.fill.png'> | p.circle.fill | <img alt='q.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/q.circle.png'> | q.circle |
|
||||
| <img alt='q.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/q.circle.fill.png'> | q.circle.fill | <img alt='r.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/r.circle.png'> | r.circle | <img alt='r.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/r.circle.fill.png'> | r.circle.fill | <img alt='s.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/s.circle.png'> | s.circle |
|
||||
| <img alt='s.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/s.circle.fill.png'> | s.circle.fill | <img alt='t.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/t.circle.png'> | t.circle | <img alt='t.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/t.circle.fill.png'> | t.circle.fill | <img alt='u.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/u.circle.png'> | u.circle |
|
||||
| <img alt='u.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/u.circle.fill.png'> | u.circle.fill | <img alt='v.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/v.circle.png'> | v.circle | <img alt='v.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/v.circle.fill.png'> | v.circle.fill | <img alt='w.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/w.circle.png'> | w.circle |
|
||||
| <img alt='w.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/w.circle.fill.png'> | w.circle.fill | <img alt='x.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/x.circle.png'> | x.circle | <img alt='x.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/x.circle.fill.png'> | x.circle.fill | <img alt='y.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/y.circle.png'> | y.circle |
|
||||
| <img alt='y.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/y.circle.fill.png'> | y.circle.fill | <img alt='z.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/z.circle.png'> | z.circle | <img alt='z.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/z.circle.fill.png'> | z.circle.fill | <img alt='dollarsign.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/dollarsign.circle.png'> | dollarsign.circle |
|
||||
| <img alt='dollarsign.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/dollarsign.circle.fill.png'> | dollarsign.circle.fill | <img alt='centsign.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/centsign.circle.png'> | centsign.circle | <img alt='centsign.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/centsign.circle.fill.png'> | centsign.circle.fill | <img alt='yensign.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/yensign.circle.png'> | yensign.circle |
|
||||
| <img alt='yensign.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/yensign.circle.fill.png'> | yensign.circle.fill | <img alt='sterlingsign.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/sterlingsign.circle.png'> | sterlingsign.circle | <img alt='sterlingsign.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/sterlingsign.circle.fill.png'> | sterlingsign.circle.fill | <img alt='francsign.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/francsign.circle.png'> | francsign.circle |
|
||||
| <img alt='francsign.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/francsign.circle.fill.png'> | francsign.circle.fill | <img alt='florinsign.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/florinsign.circle.png'> | florinsign.circle | <img alt='florinsign.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/florinsign.circle.fill.png'> | florinsign.circle.fill | <img alt='turkishlirasign.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/turkishlirasign.circle.png'> | turkishlirasign.circle |
|
||||
| <img alt='turkishlirasign.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/turkishlirasign.circle.fill.png'> | turkishlirasign.circle.fill | <img alt='rublesign.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rublesign.circle.png'> | rublesign.circle | <img alt='rublesign.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rublesign.circle.fill.png'> | rublesign.circle.fill | <img alt='eurosign.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/eurosign.circle.png'> | eurosign.circle |
|
||||
| <img alt='eurosign.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/eurosign.circle.fill.png'> | eurosign.circle.fill | <img alt='dongsign.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/dongsign.circle.png'> | dongsign.circle | <img alt='dongsign.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/dongsign.circle.fill.png'> | dongsign.circle.fill | <img alt='indianrupeesign.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/indianrupeesign.circle.png'> | indianrupeesign.circle |
|
||||
| <img alt='indianrupeesign.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/indianrupeesign.circle.fill.png'> | indianrupeesign.circle.fill | <img alt='tengesign.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/tengesign.circle.png'> | tengesign.circle | <img alt='tengesign.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/tengesign.circle.fill.png'> | tengesign.circle.fill | <img alt='pesetasign.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/pesetasign.circle.png'> | pesetasign.circle |
|
||||
| <img alt='pesetasign.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/pesetasign.circle.fill.png'> | pesetasign.circle.fill | <img alt='pesosign.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/pesosign.circle.png'> | pesosign.circle | <img alt='pesosign.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/pesosign.circle.fill.png'> | pesosign.circle.fill | <img alt='kipsign.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/kipsign.circle.png'> | kipsign.circle |
|
||||
| <img alt='kipsign.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/kipsign.circle.fill.png'> | kipsign.circle.fill | <img alt='wonsign.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/wonsign.circle.png'> | wonsign.circle | <img alt='wonsign.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/wonsign.circle.fill.png'> | wonsign.circle.fill | <img alt='lirasign.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/lirasign.circle.png'> | lirasign.circle |
|
||||
| <img alt='lirasign.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/lirasign.circle.fill.png'> | lirasign.circle.fill | <img alt='australsign.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/australsign.circle.png'> | australsign.circle | <img alt='australsign.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/australsign.circle.fill.png'> | australsign.circle.fill | <img alt='hryvniasign.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/hryvniasign.circle.png'> | hryvniasign.circle |
|
||||
| <img alt='hryvniasign.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/hryvniasign.circle.fill.png'> | hryvniasign.circle.fill | <img alt='nairasign.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/nairasign.circle.png'> | nairasign.circle | <img alt='nairasign.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/nairasign.circle.fill.png'> | nairasign.circle.fill | <img alt='guaranisign.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/guaranisign.circle.png'> | guaranisign.circle |
|
||||
| <img alt='guaranisign.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/guaranisign.circle.fill.png'> | guaranisign.circle.fill | <img alt='coloncurrencysign.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/coloncurrencysign.circle.png'> | coloncurrencysign.circle | <img alt='coloncurrencysign.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/coloncurrencysign.circle.fill.png'> | coloncurrencysign.circle.fill | <img alt='cedisign.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cedisign.circle.png'> | cedisign.circle |
|
||||
| <img alt='cedisign.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cedisign.circle.fill.png'> | cedisign.circle.fill | <img alt='cruzeirosign.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cruzeirosign.circle.png'> | cruzeirosign.circle | <img alt='cruzeirosign.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cruzeirosign.circle.fill.png'> | cruzeirosign.circle.fill | <img alt='tugriksign.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/tugriksign.circle.png'> | tugriksign.circle |
|
||||
| <img alt='tugriksign.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/tugriksign.circle.fill.png'> | tugriksign.circle.fill | <img alt='millsign.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/millsign.circle.png'> | millsign.circle | <img alt='millsign.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/millsign.circle.fill.png'> | millsign.circle.fill | <img alt='sheqelsign.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/sheqelsign.circle.png'> | sheqelsign.circle |
|
||||
| <img alt='sheqelsign.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/sheqelsign.circle.fill.png'> | sheqelsign.circle.fill | <img alt='manatsign.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/manatsign.circle.png'> | manatsign.circle | <img alt='manatsign.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/manatsign.circle.fill.png'> | manatsign.circle.fill | <img alt='rupeesign.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rupeesign.circle.png'> | rupeesign.circle |
|
||||
| <img alt='rupeesign.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rupeesign.circle.fill.png'> | rupeesign.circle.fill | <img alt='bahtsign.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bahtsign.circle.png'> | bahtsign.circle | <img alt='bahtsign.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bahtsign.circle.fill.png'> | bahtsign.circle.fill | <img alt='larisign.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/larisign.circle.png'> | larisign.circle |
|
||||
| <img alt='larisign.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/larisign.circle.fill.png'> | larisign.circle.fill | <img alt='bitcoinsign.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bitcoinsign.circle.png'> | bitcoinsign.circle | <img alt='bitcoinsign.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bitcoinsign.circle.fill.png'> | bitcoinsign.circle.fill | <img alt='0.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/0.circle.png'> | 0.circle |
|
||||
| <img alt='0.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/0.circle.fill.png'> | 0.circle.fill | <img alt='1.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/1.circle.png'> | 1.circle | <img alt='1.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/1.circle.fill.png'> | 1.circle.fill | <img alt='2.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/2.circle.png'> | 2.circle |
|
||||
| <img alt='2.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/2.circle.fill.png'> | 2.circle.fill | <img alt='3.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/3.circle.png'> | 3.circle | <img alt='3.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/3.circle.fill.png'> | 3.circle.fill | <img alt='4.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/4.circle.png'> | 4.circle |
|
||||
| <img alt='4.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/4.circle.fill.png'> | 4.circle.fill | <img alt='4.alt.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/4.alt.circle.png'> | 4.alt.circle | <img alt='4.alt.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/4.alt.circle.fill.png'> | 4.alt.circle.fill | <img alt='5.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/5.circle.png'> | 5.circle |
|
||||
| <img alt='5.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/5.circle.fill.png'> | 5.circle.fill | <img alt='6.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/6.circle.png'> | 6.circle | <img alt='6.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/6.circle.fill.png'> | 6.circle.fill | <img alt='6.alt.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/6.alt.circle.png'> | 6.alt.circle |
|
||||
| <img alt='6.alt.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/6.alt.circle.fill.png'> | 6.alt.circle.fill | <img alt='7.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/7.circle.png'> | 7.circle | <img alt='7.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/7.circle.fill.png'> | 7.circle.fill | <img alt='8.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/8.circle.png'> | 8.circle |
|
||||
| <img alt='8.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/8.circle.fill.png'> | 8.circle.fill | <img alt='9.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/9.circle.png'> | 9.circle | <img alt='9.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/9.circle.fill.png'> | 9.circle.fill | <img alt='9.alt.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/9.alt.circle.png'> | 9.alt.circle |
|
||||
| <img alt='9.alt.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/9.alt.circle.fill.png'> | 9.alt.circle.fill | <img alt='00.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/00.circle.png'> | 00.circle | <img alt='00.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/00.circle.fill.png'> | 00.circle.fill | <img alt='01.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/01.circle.png'> | 01.circle |
|
||||
| <img alt='01.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/01.circle.fill.png'> | 01.circle.fill | <img alt='02.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/02.circle.png'> | 02.circle | <img alt='02.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/02.circle.fill.png'> | 02.circle.fill | <img alt='03.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/03.circle.png'> | 03.circle |
|
||||
| <img alt='03.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/03.circle.fill.png'> | 03.circle.fill | <img alt='04.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/04.circle.png'> | 04.circle | <img alt='04.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/04.circle.fill.png'> | 04.circle.fill | <img alt='05.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/05.circle.png'> | 05.circle |
|
||||
| <img alt='05.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/05.circle.fill.png'> | 05.circle.fill | <img alt='06.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/06.circle.png'> | 06.circle | <img alt='06.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/06.circle.fill.png'> | 06.circle.fill | <img alt='07.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/07.circle.png'> | 07.circle |
|
||||
| <img alt='07.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/07.circle.fill.png'> | 07.circle.fill | <img alt='08.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/08.circle.png'> | 08.circle | <img alt='08.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/08.circle.fill.png'> | 08.circle.fill | <img alt='09.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/09.circle.png'> | 09.circle |
|
||||
| <img alt='09.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/09.circle.fill.png'> | 09.circle.fill | <img alt='10.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/10.circle.png'> | 10.circle | <img alt='10.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/10.circle.fill.png'> | 10.circle.fill | <img alt='11.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/11.circle.png'> | 11.circle |
|
||||
| <img alt='11.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/11.circle.fill.png'> | 11.circle.fill | <img alt='12.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/12.circle.png'> | 12.circle | <img alt='12.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/12.circle.fill.png'> | 12.circle.fill | <img alt='13.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/13.circle.png'> | 13.circle |
|
||||
| <img alt='13.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/13.circle.fill.png'> | 13.circle.fill | <img alt='14.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/14.circle.png'> | 14.circle | <img alt='14.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/14.circle.fill.png'> | 14.circle.fill | <img alt='15.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/15.circle.png'> | 15.circle |
|
||||
| <img alt='15.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/15.circle.fill.png'> | 15.circle.fill | <img alt='16.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/16.circle.png'> | 16.circle | <img alt='16.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/16.circle.fill.png'> | 16.circle.fill | <img alt='17.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/17.circle.png'> | 17.circle |
|
||||
| <img alt='17.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/17.circle.fill.png'> | 17.circle.fill | <img alt='18.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/18.circle.png'> | 18.circle | <img alt='18.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/18.circle.fill.png'> | 18.circle.fill | <img alt='19.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/19.circle.png'> | 19.circle |
|
||||
| <img alt='19.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/19.circle.fill.png'> | 19.circle.fill | <img alt='20.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/20.circle.png'> | 20.circle | <img alt='20.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/20.circle.fill.png'> | 20.circle.fill | <img alt='21.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/21.circle.png'> | 21.circle |
|
||||
| <img alt='21.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/21.circle.fill.png'> | 21.circle.fill | <img alt='22.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/22.circle.png'> | 22.circle | <img alt='22.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/22.circle.fill.png'> | 22.circle.fill | <img alt='23.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/23.circle.png'> | 23.circle |
|
||||
| <img alt='23.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/23.circle.fill.png'> | 23.circle.fill | <img alt='24.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/24.circle.png'> | 24.circle | <img alt='24.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/24.circle.fill.png'> | 24.circle.fill | <img alt='25.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/25.circle.png'> | 25.circle |
|
||||
| <img alt='25.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/25.circle.fill.png'> | 25.circle.fill | <img alt='26.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/26.circle.png'> | 26.circle | <img alt='26.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/26.circle.fill.png'> | 26.circle.fill | <img alt='27.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/27.circle.png'> | 27.circle |
|
||||
| <img alt='27.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/27.circle.fill.png'> | 27.circle.fill | <img alt='28.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/28.circle.png'> | 28.circle | <img alt='28.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/28.circle.fill.png'> | 28.circle.fill | <img alt='29.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/29.circle.png'> | 29.circle |
|
||||
| <img alt='29.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/29.circle.fill.png'> | 29.circle.fill | <img alt='30.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/30.circle.png'> | 30.circle | <img alt='30.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/30.circle.fill.png'> | 30.circle.fill | <img alt='31.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/31.circle.png'> | 31.circle |
|
||||
| <img alt='31.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/31.circle.fill.png'> | 31.circle.fill | <img alt='32.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/32.circle.png'> | 32.circle | <img alt='32.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/32.circle.fill.png'> | 32.circle.fill | <img alt='33.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/33.circle.png'> | 33.circle |
|
||||
| <img alt='33.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/33.circle.fill.png'> | 33.circle.fill | <img alt='34.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/34.circle.png'> | 34.circle | <img alt='34.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/34.circle.fill.png'> | 34.circle.fill | <img alt='35.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/35.circle.png'> | 35.circle |
|
||||
| <img alt='35.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/35.circle.fill.png'> | 35.circle.fill | <img alt='36.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/36.circle.png'> | 36.circle | <img alt='36.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/36.circle.fill.png'> | 36.circle.fill | <img alt='37.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/37.circle.png'> | 37.circle |
|
||||
| <img alt='37.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/37.circle.fill.png'> | 37.circle.fill | <img alt='38.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/38.circle.png'> | 38.circle | <img alt='38.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/38.circle.fill.png'> | 38.circle.fill | <img alt='39.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/39.circle.png'> | 39.circle |
|
||||
| <img alt='39.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/39.circle.fill.png'> | 39.circle.fill | <img alt='40.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/40.circle.png'> | 40.circle | <img alt='40.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/40.circle.fill.png'> | 40.circle.fill | <img alt='41.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/41.circle.png'> | 41.circle |
|
||||
| <img alt='41.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/41.circle.fill.png'> | 41.circle.fill | <img alt='42.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/42.circle.png'> | 42.circle | <img alt='42.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/42.circle.fill.png'> | 42.circle.fill | <img alt='43.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/43.circle.png'> | 43.circle |
|
||||
| <img alt='43.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/43.circle.fill.png'> | 43.circle.fill | <img alt='44.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/44.circle.png'> | 44.circle | <img alt='44.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/44.circle.fill.png'> | 44.circle.fill | <img alt='45.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/45.circle.png'> | 45.circle |
|
||||
| <img alt='45.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/45.circle.fill.png'> | 45.circle.fill | <img alt='46.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/46.circle.png'> | 46.circle | <img alt='46.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/46.circle.fill.png'> | 46.circle.fill | <img alt='47.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/47.circle.png'> | 47.circle |
|
||||
| <img alt='47.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/47.circle.fill.png'> | 47.circle.fill | <img alt='48.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/48.circle.png'> | 48.circle | <img alt='48.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/48.circle.fill.png'> | 48.circle.fill | <img alt='49.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/49.circle.png'> | 49.circle |
|
||||
| <img alt='49.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/49.circle.fill.png'> | 49.circle.fill | <img alt='50.circle' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/50.circle.png'> | 50.circle | <img alt='50.circle.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/50.circle.fill.png'> | 50.circle.fill | <img alt='square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.png'> | square |
|
||||
| <img alt='square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.fill.png'> | square.fill | <img alt='square.lefthalf.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.lefthalf.fill.png'> | square.lefthalf.fill | <img alt='square.righthalf.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/square.righthalf.fill.png'> | square.righthalf.fill | <img alt='a.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/a.square.png'> | a.square |
|
||||
| <img alt='a.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/a.square.fill.png'> | a.square.fill | <img alt='b.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/b.square.png'> | b.square | <img alt='b.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/b.square.fill.png'> | b.square.fill | <img alt='c.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/c.square.png'> | c.square |
|
||||
| <img alt='c.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/c.square.fill.png'> | c.square.fill | <img alt='d.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/d.square.png'> | d.square | <img alt='d.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/d.square.fill.png'> | d.square.fill | <img alt='e.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/e.square.png'> | e.square |
|
||||
| <img alt='e.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/e.square.fill.png'> | e.square.fill | <img alt='f.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/f.square.png'> | f.square | <img alt='f.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/f.square.fill.png'> | f.square.fill | <img alt='g.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/g.square.png'> | g.square |
|
||||
| <img alt='g.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/g.square.fill.png'> | g.square.fill | <img alt='h.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/h.square.png'> | h.square | <img alt='h.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/h.square.fill.png'> | h.square.fill | <img alt='i.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/i.square.png'> | i.square |
|
||||
| <img alt='i.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/i.square.fill.png'> | i.square.fill | <img alt='j.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/j.square.png'> | j.square | <img alt='j.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/j.square.fill.png'> | j.square.fill | <img alt='k.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/k.square.png'> | k.square |
|
||||
| <img alt='k.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/k.square.fill.png'> | k.square.fill | <img alt='l.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/l.square.png'> | l.square | <img alt='l.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/l.square.fill.png'> | l.square.fill | <img alt='m.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/m.square.png'> | m.square |
|
||||
| <img alt='m.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/m.square.fill.png'> | m.square.fill | <img alt='n.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/n.square.png'> | n.square | <img alt='n.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/n.square.fill.png'> | n.square.fill | <img alt='o.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/o.square.png'> | o.square |
|
||||
| <img alt='o.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/o.square.fill.png'> | o.square.fill | <img alt='p.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/p.square.png'> | p.square | <img alt='p.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/p.square.fill.png'> | p.square.fill | <img alt='q.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/q.square.png'> | q.square |
|
||||
| <img alt='q.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/q.square.fill.png'> | q.square.fill | <img alt='r.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/r.square.png'> | r.square | <img alt='r.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/r.square.fill.png'> | r.square.fill | <img alt='s.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/s.square.png'> | s.square |
|
||||
| <img alt='s.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/s.square.fill.png'> | s.square.fill | <img alt='t.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/t.square.png'> | t.square | <img alt='t.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/t.square.fill.png'> | t.square.fill | <img alt='u.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/u.square.png'> | u.square |
|
||||
| <img alt='u.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/u.square.fill.png'> | u.square.fill | <img alt='v.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/v.square.png'> | v.square | <img alt='v.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/v.square.fill.png'> | v.square.fill | <img alt='w.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/w.square.png'> | w.square |
|
||||
| <img alt='w.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/w.square.fill.png'> | w.square.fill | <img alt='x.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/x.square.png'> | x.square | <img alt='x.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/x.square.fill.png'> | x.square.fill | <img alt='y.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/y.square.png'> | y.square |
|
||||
| <img alt='y.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/y.square.fill.png'> | y.square.fill | <img alt='z.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/z.square.png'> | z.square | <img alt='z.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/z.square.fill.png'> | z.square.fill | <img alt='dollarsign.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/dollarsign.square.png'> | dollarsign.square |
|
||||
| <img alt='dollarsign.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/dollarsign.square.fill.png'> | dollarsign.square.fill | <img alt='centsign.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/centsign.square.png'> | centsign.square | <img alt='centsign.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/centsign.square.fill.png'> | centsign.square.fill | <img alt='yensign.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/yensign.square.png'> | yensign.square |
|
||||
| <img alt='yensign.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/yensign.square.fill.png'> | yensign.square.fill | <img alt='sterlingsign.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/sterlingsign.square.png'> | sterlingsign.square | <img alt='sterlingsign.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/sterlingsign.square.fill.png'> | sterlingsign.square.fill | <img alt='francsign.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/francsign.square.png'> | francsign.square |
|
||||
| <img alt='francsign.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/francsign.square.fill.png'> | francsign.square.fill | <img alt='florinsign.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/florinsign.square.png'> | florinsign.square | <img alt='florinsign.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/florinsign.square.fill.png'> | florinsign.square.fill | <img alt='turkishlirasign.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/turkishlirasign.square.png'> | turkishlirasign.square |
|
||||
| <img alt='turkishlirasign.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/turkishlirasign.square.fill.png'> | turkishlirasign.square.fill | <img alt='rublesign.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rublesign.square.png'> | rublesign.square | <img alt='rublesign.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rublesign.square.fill.png'> | rublesign.square.fill | <img alt='eurosign.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/eurosign.square.png'> | eurosign.square |
|
||||
| <img alt='eurosign.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/eurosign.square.fill.png'> | eurosign.square.fill | <img alt='dongsign.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/dongsign.square.png'> | dongsign.square | <img alt='dongsign.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/dongsign.square.fill.png'> | dongsign.square.fill | <img alt='indianrupeesign.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/indianrupeesign.square.png'> | indianrupeesign.square |
|
||||
| <img alt='indianrupeesign.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/indianrupeesign.square.fill.png'> | indianrupeesign.square.fill | <img alt='tengesign.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/tengesign.square.png'> | tengesign.square | <img alt='tengesign.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/tengesign.square.fill.png'> | tengesign.square.fill | <img alt='pesetasign.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/pesetasign.square.png'> | pesetasign.square |
|
||||
| <img alt='pesetasign.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/pesetasign.square.fill.png'> | pesetasign.square.fill | <img alt='pesosign.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/pesosign.square.png'> | pesosign.square | <img alt='pesosign.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/pesosign.square.fill.png'> | pesosign.square.fill | <img alt='kipsign.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/kipsign.square.png'> | kipsign.square |
|
||||
| <img alt='kipsign.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/kipsign.square.fill.png'> | kipsign.square.fill | <img alt='wonsign.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/wonsign.square.png'> | wonsign.square | <img alt='wonsign.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/wonsign.square.fill.png'> | wonsign.square.fill | <img alt='lirasign.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/lirasign.square.png'> | lirasign.square |
|
||||
| <img alt='lirasign.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/lirasign.square.fill.png'> | lirasign.square.fill | <img alt='australsign.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/australsign.square.png'> | australsign.square | <img alt='australsign.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/australsign.square.fill.png'> | australsign.square.fill | <img alt='hryvniasign.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/hryvniasign.square.png'> | hryvniasign.square |
|
||||
| <img alt='hryvniasign.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/hryvniasign.square.fill.png'> | hryvniasign.square.fill | <img alt='nairasign.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/nairasign.square.png'> | nairasign.square | <img alt='nairasign.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/nairasign.square.fill.png'> | nairasign.square.fill | <img alt='guaranisign.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/guaranisign.square.png'> | guaranisign.square |
|
||||
| <img alt='guaranisign.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/guaranisign.square.fill.png'> | guaranisign.square.fill | <img alt='coloncurrencysign.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/coloncurrencysign.square.png'> | coloncurrencysign.square | <img alt='coloncurrencysign.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/coloncurrencysign.square.fill.png'> | coloncurrencysign.square.fill | <img alt='cedisign.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cedisign.square.png'> | cedisign.square |
|
||||
| <img alt='cedisign.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cedisign.square.fill.png'> | cedisign.square.fill | <img alt='cruzeirosign.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cruzeirosign.square.png'> | cruzeirosign.square | <img alt='cruzeirosign.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/cruzeirosign.square.fill.png'> | cruzeirosign.square.fill | <img alt='tugriksign.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/tugriksign.square.png'> | tugriksign.square |
|
||||
| <img alt='tugriksign.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/tugriksign.square.fill.png'> | tugriksign.square.fill | <img alt='millsign.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/millsign.square.png'> | millsign.square | <img alt='millsign.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/millsign.square.fill.png'> | millsign.square.fill | <img alt='sheqelsign.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/sheqelsign.square.png'> | sheqelsign.square |
|
||||
| <img alt='sheqelsign.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/sheqelsign.square.fill.png'> | sheqelsign.square.fill | <img alt='manatsign.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/manatsign.square.png'> | manatsign.square | <img alt='manatsign.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/manatsign.square.fill.png'> | manatsign.square.fill | <img alt='rupeesign.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rupeesign.square.png'> | rupeesign.square |
|
||||
| <img alt='rupeesign.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/rupeesign.square.fill.png'> | rupeesign.square.fill | <img alt='bahtsign.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bahtsign.square.png'> | bahtsign.square | <img alt='bahtsign.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bahtsign.square.fill.png'> | bahtsign.square.fill | <img alt='larisign.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/larisign.square.png'> | larisign.square |
|
||||
| <img alt='larisign.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/larisign.square.fill.png'> | larisign.square.fill | <img alt='bitcoinsign.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bitcoinsign.square.png'> | bitcoinsign.square | <img alt='bitcoinsign.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/bitcoinsign.square.fill.png'> | bitcoinsign.square.fill | <img alt='0.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/0.square.png'> | 0.square |
|
||||
| <img alt='0.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/0.square.fill.png'> | 0.square.fill | <img alt='1.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/1.square.png'> | 1.square | <img alt='1.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/1.square.fill.png'> | 1.square.fill | <img alt='2.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/2.square.png'> | 2.square |
|
||||
| <img alt='2.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/2.square.fill.png'> | 2.square.fill | <img alt='3.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/3.square.png'> | 3.square | <img alt='3.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/3.square.fill.png'> | 3.square.fill | <img alt='4.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/4.square.png'> | 4.square |
|
||||
| <img alt='4.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/4.square.fill.png'> | 4.square.fill | <img alt='4.alt.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/4.alt.square.png'> | 4.alt.square | <img alt='4.alt.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/4.alt.square.fill.png'> | 4.alt.square.fill | <img alt='5.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/5.square.png'> | 5.square |
|
||||
| <img alt='5.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/5.square.fill.png'> | 5.square.fill | <img alt='6.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/6.square.png'> | 6.square | <img alt='6.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/6.square.fill.png'> | 6.square.fill | <img alt='6.alt.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/6.alt.square.png'> | 6.alt.square |
|
||||
| <img alt='6.alt.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/6.alt.square.fill.png'> | 6.alt.square.fill | <img alt='7.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/7.square.png'> | 7.square | <img alt='7.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/7.square.fill.png'> | 7.square.fill | <img alt='8.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/8.square.png'> | 8.square |
|
||||
| <img alt='8.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/8.square.fill.png'> | 8.square.fill | <img alt='9.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/9.square.png'> | 9.square | <img alt='9.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/9.square.fill.png'> | 9.square.fill | <img alt='9.alt.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/9.alt.square.png'> | 9.alt.square |
|
||||
| <img alt='9.alt.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/9.alt.square.fill.png'> | 9.alt.square.fill | <img alt='00.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/00.square.png'> | 00.square | <img alt='00.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/00.square.fill.png'> | 00.square.fill | <img alt='01.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/01.square.png'> | 01.square |
|
||||
| <img alt='01.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/01.square.fill.png'> | 01.square.fill | <img alt='02.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/02.square.png'> | 02.square | <img alt='02.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/02.square.fill.png'> | 02.square.fill | <img alt='03.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/03.square.png'> | 03.square |
|
||||
| <img alt='03.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/03.square.fill.png'> | 03.square.fill | <img alt='04.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/04.square.png'> | 04.square | <img alt='04.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/04.square.fill.png'> | 04.square.fill | <img alt='05.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/05.square.png'> | 05.square |
|
||||
| <img alt='05.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/05.square.fill.png'> | 05.square.fill | <img alt='06.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/06.square.png'> | 06.square | <img alt='06.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/06.square.fill.png'> | 06.square.fill | <img alt='07.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/07.square.png'> | 07.square |
|
||||
| <img alt='07.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/07.square.fill.png'> | 07.square.fill | <img alt='08.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/08.square.png'> | 08.square | <img alt='08.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/08.square.fill.png'> | 08.square.fill | <img alt='09.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/09.square.png'> | 09.square |
|
||||
| <img alt='09.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/09.square.fill.png'> | 09.square.fill | <img alt='10.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/10.square.png'> | 10.square | <img alt='10.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/10.square.fill.png'> | 10.square.fill | <img alt='11.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/11.square.png'> | 11.square |
|
||||
| <img alt='11.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/11.square.fill.png'> | 11.square.fill | <img alt='12.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/12.square.png'> | 12.square | <img alt='12.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/12.square.fill.png'> | 12.square.fill | <img alt='13.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/13.square.png'> | 13.square |
|
||||
| <img alt='13.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/13.square.fill.png'> | 13.square.fill | <img alt='14.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/14.square.png'> | 14.square | <img alt='14.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/14.square.fill.png'> | 14.square.fill | <img alt='15.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/15.square.png'> | 15.square |
|
||||
| <img alt='15.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/15.square.fill.png'> | 15.square.fill | <img alt='16.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/16.square.png'> | 16.square | <img alt='16.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/16.square.fill.png'> | 16.square.fill | <img alt='17.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/17.square.png'> | 17.square |
|
||||
| <img alt='17.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/17.square.fill.png'> | 17.square.fill | <img alt='18.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/18.square.png'> | 18.square | <img alt='18.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/18.square.fill.png'> | 18.square.fill | <img alt='19.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/19.square.png'> | 19.square |
|
||||
| <img alt='19.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/19.square.fill.png'> | 19.square.fill | <img alt='20.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/20.square.png'> | 20.square | <img alt='20.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/20.square.fill.png'> | 20.square.fill | <img alt='21.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/21.square.png'> | 21.square |
|
||||
| <img alt='21.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/21.square.fill.png'> | 21.square.fill | <img alt='22.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/22.square.png'> | 22.square | <img alt='22.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/22.square.fill.png'> | 22.square.fill | <img alt='23.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/23.square.png'> | 23.square |
|
||||
| <img alt='23.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/23.square.fill.png'> | 23.square.fill | <img alt='24.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/24.square.png'> | 24.square | <img alt='24.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/24.square.fill.png'> | 24.square.fill | <img alt='25.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/25.square.png'> | 25.square |
|
||||
| <img alt='25.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/25.square.fill.png'> | 25.square.fill | <img alt='26.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/26.square.png'> | 26.square | <img alt='26.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/26.square.fill.png'> | 26.square.fill | <img alt='27.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/27.square.png'> | 27.square |
|
||||
| <img alt='27.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/27.square.fill.png'> | 27.square.fill | <img alt='28.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/28.square.png'> | 28.square | <img alt='28.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/28.square.fill.png'> | 28.square.fill | <img alt='29.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/29.square.png'> | 29.square |
|
||||
| <img alt='29.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/29.square.fill.png'> | 29.square.fill | <img alt='30.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/30.square.png'> | 30.square | <img alt='30.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/30.square.fill.png'> | 30.square.fill | <img alt='31.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/31.square.png'> | 31.square |
|
||||
| <img alt='31.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/31.square.fill.png'> | 31.square.fill | <img alt='32.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/32.square.png'> | 32.square | <img alt='32.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/32.square.fill.png'> | 32.square.fill | <img alt='33.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/33.square.png'> | 33.square |
|
||||
| <img alt='33.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/33.square.fill.png'> | 33.square.fill | <img alt='34.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/34.square.png'> | 34.square | <img alt='34.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/34.square.fill.png'> | 34.square.fill | <img alt='35.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/35.square.png'> | 35.square |
|
||||
| <img alt='35.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/35.square.fill.png'> | 35.square.fill | <img alt='36.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/36.square.png'> | 36.square | <img alt='36.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/36.square.fill.png'> | 36.square.fill | <img alt='37.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/37.square.png'> | 37.square |
|
||||
| <img alt='37.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/37.square.fill.png'> | 37.square.fill | <img alt='38.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/38.square.png'> | 38.square | <img alt='38.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/38.square.fill.png'> | 38.square.fill | <img alt='39.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/39.square.png'> | 39.square |
|
||||
| <img alt='39.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/39.square.fill.png'> | 39.square.fill | <img alt='40.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/40.square.png'> | 40.square | <img alt='40.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/40.square.fill.png'> | 40.square.fill | <img alt='41.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/41.square.png'> | 41.square |
|
||||
| <img alt='41.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/41.square.fill.png'> | 41.square.fill | <img alt='42.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/42.square.png'> | 42.square | <img alt='42.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/42.square.fill.png'> | 42.square.fill | <img alt='43.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/43.square.png'> | 43.square |
|
||||
| <img alt='43.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/43.square.fill.png'> | 43.square.fill | <img alt='44.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/44.square.png'> | 44.square | <img alt='44.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/44.square.fill.png'> | 44.square.fill | <img alt='45.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/45.square.png'> | 45.square |
|
||||
| <img alt='45.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/45.square.fill.png'> | 45.square.fill | <img alt='46.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/46.square.png'> | 46.square | <img alt='46.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/46.square.fill.png'> | 46.square.fill | <img alt='47.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/47.square.png'> | 47.square |
|
||||
| <img alt='47.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/47.square.fill.png'> | 47.square.fill | <img alt='48.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/48.square.png'> | 48.square | <img alt='48.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/48.square.fill.png'> | 48.square.fill | <img alt='49.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/49.square.png'> | 49.square |
|
||||
| <img alt='49.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/49.square.fill.png'> | 49.square.fill | <img alt='50.square' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/50.square.png'> | 50.square | <img alt='50.square.fill' src='https://raw.githubusercontent.com/andrewtavis/sf-symbols-online/refs/heads/master/glyphs_white/50.square.fill.png'> | 50.square.fill |
|
||||
<!--prettier-ignore-end-->
|
||||
1204
src/api.rs
Normal file
69
src/db.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use crate::models::DBVideo;
|
||||
use diesel::prelude::*;
|
||||
|
||||
pub fn get_video(
|
||||
conn: &mut SqliteConnection,
|
||||
video_id: String,
|
||||
) -> Result<Option<String>, diesel::result::Error> {
|
||||
use crate::schema::videos::dsl::*;
|
||||
let result = videos
|
||||
.filter(id.eq(video_id))
|
||||
.first::<DBVideo>(conn)
|
||||
.optional()?;
|
||||
match result {
|
||||
Some(video) => Ok(Some(video.url)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert_video(
|
||||
conn: &mut SqliteConnection,
|
||||
new_id: &str,
|
||||
new_url: &str,
|
||||
) -> Result<usize, diesel::result::Error> {
|
||||
use crate::schema::videos::dsl::*;
|
||||
diesel::insert_into(videos)
|
||||
.values(DBVideo {
|
||||
id: new_id.to_string(),
|
||||
url: new_url.to_string(),
|
||||
})
|
||||
.execute(conn)
|
||||
}
|
||||
|
||||
pub fn delete_video(
|
||||
conn: &mut SqliteConnection,
|
||||
video_id: String,
|
||||
) -> Result<usize, diesel::result::Error> {
|
||||
use crate::schema::videos::dsl::*;
|
||||
diesel::delete(videos.filter(id.eq(video_id))).execute(conn)
|
||||
}
|
||||
|
||||
pub fn has_table(
|
||||
conn: &mut SqliteConnection,
|
||||
table_name: &str,
|
||||
) -> Result<bool, diesel::result::Error> {
|
||||
use diesel::sql_query;
|
||||
use diesel::sql_types::Text;
|
||||
#[derive(QueryableByName)]
|
||||
struct TableName {
|
||||
#[diesel(sql_type = Text)]
|
||||
#[diesel(column_name = name)]
|
||||
name: String,
|
||||
}
|
||||
|
||||
let query = "SELECT name FROM sqlite_master WHERE type='table' AND name = ?1";
|
||||
let rows = sql_query(query)
|
||||
.bind::<Text, _>(table_name)
|
||||
.load::<TableName>(conn)?;
|
||||
let exists = rows.first().map(|r| !r.name.is_empty()).unwrap_or(false);
|
||||
Ok(exists)
|
||||
}
|
||||
|
||||
pub fn create_table(
|
||||
conn: &mut SqliteConnection,
|
||||
create_sql: &str,
|
||||
) -> Result<(), diesel::result::Error> {
|
||||
use diesel::sql_query;
|
||||
sql_query(create_sql).execute(conn)?;
|
||||
Ok(())
|
||||
}
|
||||
94
src/main.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
#![warn(unused_extern_crates)]
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
|
||||
use std::{env, thread};
|
||||
|
||||
use diesel::{r2d2::{self, ConnectionManager}, SqliteConnection};
|
||||
use dotenvy::dotenv;
|
||||
use ntex_files as fs;
|
||||
use ntex::web;
|
||||
|
||||
mod api;
|
||||
mod proxy;
|
||||
mod db;
|
||||
mod models;
|
||||
mod providers;
|
||||
mod schema;
|
||||
mod status;
|
||||
mod util;
|
||||
mod videos;
|
||||
mod proxies;
|
||||
|
||||
type DbPool = r2d2::Pool<ConnectionManager<SqliteConnection>>;
|
||||
|
||||
// #[macro_use(c)]
|
||||
// extern crate cute;
|
||||
|
||||
|
||||
#[ntex::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
// std::env::set_var("RUST_BACKTRACE", "1");
|
||||
dotenv().ok();
|
||||
|
||||
// Enable request logging
|
||||
if std::env::var("RUST_LOG").is_err() {
|
||||
unsafe{
|
||||
std::env::set_var("RUST_LOG", "warn");
|
||||
}
|
||||
}
|
||||
env_logger::init(); // You need this to actually see logs
|
||||
|
||||
// set up database connection pool
|
||||
let connspec = std::env::var("DATABASE_URL").expect("DATABASE_URL");
|
||||
let manager = ConnectionManager::<SqliteConnection>::new(connspec.clone());
|
||||
let pool = r2d2::Pool::builder()
|
||||
.build(manager)
|
||||
.expect("Failed to create pool.");
|
||||
|
||||
let mut requester = util::requester::Requester::new();
|
||||
requester.set_proxy(env::var("PROXY").unwrap_or("0".to_string()) != "0".to_string());
|
||||
|
||||
let cache: util::cache::VideoCache = crate::util::cache::VideoCache::new().max_size(100_000).to_owned();
|
||||
|
||||
thread::spawn(move || {
|
||||
// Create a tiny runtime just for these async tasks
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("build tokio runtime");
|
||||
|
||||
rt.block_on(async move {
|
||||
providers::init_providers_now();
|
||||
});
|
||||
});
|
||||
|
||||
web::HttpServer::new(move || {
|
||||
web::App::new()
|
||||
.state(pool.clone())
|
||||
.state(cache.clone())
|
||||
.state(requester.clone())
|
||||
.wrap(web::middleware::Logger::default())
|
||||
.service(web::scope("/api").configure(api::config))
|
||||
.service(web::scope("/proxy").configure(proxy::config))
|
||||
.service(
|
||||
web::resource("/")
|
||||
.route(web::get().to(|req: web::HttpRequest| async move{
|
||||
let host = match std::env::var("DOMAIN"){
|
||||
Ok(d) => d,
|
||||
Err(_) => req.connection_info().host().to_string()
|
||||
};
|
||||
let source_forward_header = format!("hottub://source?url={}", host);
|
||||
web::HttpResponse::Found()
|
||||
.header("Location", source_forward_header)
|
||||
.finish()
|
||||
}))
|
||||
)
|
||||
.service(fs::Files::new("/", "static").index_file("index.html"))
|
||||
})
|
||||
.workers(8)
|
||||
// .bind_openssl(("0.0.0.0", 18080), builder)?
|
||||
.bind(("0.0.0.0", 18080))?
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
10
src/models.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
use diesel::prelude::*;
|
||||
use serde::{Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Queryable, Insertable)]
|
||||
#[diesel(table_name = crate::schema::videos)]
|
||||
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
|
||||
pub struct DBVideo {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
}
|
||||
121
src/providers/all.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
use std::fs;
|
||||
use std::time::Duration;
|
||||
use async_trait::async_trait;
|
||||
use error_chain::error_chain;
|
||||
use futures::StreamExt;
|
||||
use futures::stream::FuturesUnordered;
|
||||
use crate::api::{get_provider, ClientVersion};
|
||||
use crate::providers::{DynProvider, Provider};
|
||||
use crate::status::Channel;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::interleave;
|
||||
use crate::videos::{ServerOptions, VideoItem};
|
||||
use crate::DbPool;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct AllProvider {
|
||||
}
|
||||
|
||||
impl AllProvider {
|
||||
pub fn new() -> Self {
|
||||
AllProvider {}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for AllProvider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
pool: DbPool,
|
||||
sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
per_page: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let mut sites_str = options.clone().sites.unwrap_or_default();
|
||||
if sites_str.is_empty() {
|
||||
let files = fs::read_dir("./src/providers").unwrap();
|
||||
let providers = files.map(|entry| entry.unwrap().file_name())
|
||||
.filter(|name| name.to_str().unwrap().ends_with(".rs"))
|
||||
.filter(|name| !name.to_str().unwrap().contains("mod.rs") && !name.to_str().unwrap().contains("all.rs"))
|
||||
.map(|name| name.to_str().unwrap().replace(".rs", ""))
|
||||
.collect::<Vec<String>>();
|
||||
sites_str = providers.join(",");
|
||||
}
|
||||
|
||||
let providers: Vec<DynProvider> = sites_str
|
||||
.split(',')
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter_map(|s| get_provider(s))
|
||||
.collect();
|
||||
|
||||
let mut futures = FuturesUnordered::new();
|
||||
|
||||
for provider in providers {
|
||||
let cache = cache.clone();
|
||||
let pool = pool.clone();
|
||||
let sort = sort.clone();
|
||||
let query = query.clone();
|
||||
let page = page.clone();
|
||||
let per_page = per_page.clone();
|
||||
let options = options.clone();
|
||||
|
||||
// Spawn the task so it lives independently of this function
|
||||
futures.push(tokio::spawn(async move {
|
||||
provider.get_videos(cache, pool, sort, query, page, per_page, options).await
|
||||
}));
|
||||
}
|
||||
|
||||
let mut all_results = Vec::new();
|
||||
let timeout_timer = tokio::time::sleep(Duration::from_secs(10));
|
||||
tokio::pin!(timeout_timer);
|
||||
|
||||
// Collect what we can within 55 seconds
|
||||
loop {
|
||||
tokio::select! {
|
||||
Some(result) = futures.next() => {
|
||||
// Ignore errors (panics or task cancellations)
|
||||
if let Ok(videos) = result {
|
||||
all_results.push(videos);
|
||||
}
|
||||
},
|
||||
_ = &mut timeout_timer => {
|
||||
// 55 seconds passed. Stop waiting and return what we have.
|
||||
// The tasks remaining in 'futures' will continue running in the
|
||||
// background because they were 'tokio::spawn'ed.
|
||||
break;
|
||||
},
|
||||
else => break, // All tasks finished before the timeout
|
||||
}
|
||||
}
|
||||
|
||||
interleave(&all_results)
|
||||
}
|
||||
|
||||
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
||||
let _ = clientversion;
|
||||
Some(Channel {
|
||||
id: "placeholder".to_string(),
|
||||
name: "PLACEHOLDER".to_string(),
|
||||
description: "PLACEHOLDER FOR PARENT CLASS".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://hottub.spacemoehre.de/favicon.ico".to_string(),
|
||||
status: "active".to_string(),
|
||||
categories: vec![],
|
||||
options: vec![],
|
||||
nsfw: true,
|
||||
cacheDuration: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
382
src/providers/beeg.rs
Normal file
@@ -0,0 +1,382 @@
|
||||
use crate::DbPool;
|
||||
use crate::api::ClientVersion;
|
||||
use crate::providers::Provider;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::parse_abbreviated_number;
|
||||
use crate::videos::{ServerOptions, VideoItem};
|
||||
use crate::{status::*, util};
|
||||
use async_trait::async_trait;
|
||||
use error_chain::error_chain;
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
use serde_json::Value;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::thread;
|
||||
use std::vec;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
Json(serde_json::Error);
|
||||
}
|
||||
errors {
|
||||
Parse(msg: String) {
|
||||
description("parse error")
|
||||
display("parse error: {}", msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BeegProvider {
|
||||
sites: Arc<RwLock<Vec<FilterOption>>>,
|
||||
stars: Arc<RwLock<Vec<FilterOption>>>,
|
||||
categories: Arc<RwLock<Vec<FilterOption>>>,
|
||||
}
|
||||
|
||||
impl BeegProvider {
|
||||
pub fn new() -> Self {
|
||||
let provider = BeegProvider {
|
||||
sites: Arc::new(RwLock::new(vec![FilterOption { id: "all".into(), title: "All".into() }])),
|
||||
stars: Arc::new(RwLock::new(vec![FilterOption { id: "all".into(), title: "All".into() }])),
|
||||
categories: Arc::new(RwLock::new(vec![FilterOption { id: "all".into(), title: "All".into() }])),
|
||||
};
|
||||
|
||||
provider.spawn_initial_load();
|
||||
provider
|
||||
}
|
||||
|
||||
fn spawn_initial_load(&self) {
|
||||
let sites = Arc::clone(&self.sites);
|
||||
let categories = Arc::clone(&self.categories);
|
||||
let stars = Arc::clone(&self.stars);
|
||||
|
||||
thread::spawn(move || {
|
||||
let rt = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
eprintln!("beeg runtime init failed: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
rt.block_on(async move {
|
||||
if let Err(e) = Self::load_sites(sites).await {
|
||||
eprintln!("beeg load_sites failed: {}", e);
|
||||
}
|
||||
if let Err(e) = Self::load_categories(categories).await {
|
||||
eprintln!("beeg load_categories failed: {}", e);
|
||||
}
|
||||
if let Err(e) = Self::load_stars(stars).await {
|
||||
eprintln!("beeg load_stars failed: {}", e);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async fn fetch_tags() -> Result<Value> {
|
||||
let mut requester = util::requester::Requester::new();
|
||||
let text = match requester
|
||||
.get("https://store.externulls.com/tag/facts/tags?get_original=true&slug=index", None)
|
||||
.await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
eprintln!("beeg fetch_tags failed: {}", e);
|
||||
return Err(ErrorKind::Parse("failed to fetch tags".into()).into());
|
||||
}
|
||||
};
|
||||
Ok(serde_json::from_str(&text)?)
|
||||
}
|
||||
|
||||
async fn load_stars(stars: Arc<RwLock<Vec<FilterOption>>>) -> Result<()> {
|
||||
let json = Self::fetch_tags().await?;
|
||||
let arr = json
|
||||
.get("human")
|
||||
.and_then(|v| v.as_array().map(|v| v.as_slice()))
|
||||
.unwrap_or(&[]);
|
||||
for s in arr {
|
||||
if let (Some(name), Some(id)) = (
|
||||
s.get("tg_name").and_then(|v| v.as_str()),
|
||||
s.get("tg_slug").and_then(|v| v.as_str()),
|
||||
) {
|
||||
Self::push_unique(&stars, FilterOption { id: id.into(), title: name.into() });
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_categories(categories: Arc<RwLock<Vec<FilterOption>>>) -> Result<()> {
|
||||
let json = Self::fetch_tags().await?;
|
||||
let arr = json
|
||||
.get("other")
|
||||
.and_then(|v| v.as_array().map(|v| v.as_slice()))
|
||||
.unwrap_or(&[]);
|
||||
for s in arr {
|
||||
if let (Some(name), Some(id)) = (
|
||||
s.get("tg_name").and_then(|v| v.as_str()),
|
||||
s.get("tg_slug").and_then(|v| v.as_str()),
|
||||
) {
|
||||
Self::push_unique(
|
||||
&categories,
|
||||
FilterOption {
|
||||
id: id.replace('{', "").replace('}', ""),
|
||||
title: name.replace('{', "").replace('}', ""),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_sites(sites: Arc<RwLock<Vec<FilterOption>>>) -> Result<()> {
|
||||
let json = Self::fetch_tags().await?;
|
||||
let arr = json
|
||||
.get("productions")
|
||||
.and_then(|v| v.as_array().map(|v| v.as_slice()))
|
||||
.unwrap_or(&[]);
|
||||
for s in arr {
|
||||
if let (Some(name), Some(id)) = (
|
||||
s.get("tg_name").and_then(|v| v.as_str()),
|
||||
s.get("tg_slug").and_then(|v| v.as_str()),
|
||||
) {
|
||||
Self::push_unique(&sites, FilterOption { id: id.into(), title: name.into() });
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn push_unique(target: &Arc<RwLock<Vec<FilterOption>>>, item: FilterOption) {
|
||||
if let Ok(mut vec) = target.write() {
|
||||
if !vec.iter().any(|x| x.id == item.id) {
|
||||
vec.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_channel(&self, _: ClientVersion) -> Channel {
|
||||
Channel {
|
||||
id: "beeg".into(),
|
||||
name: "Beeg".into(),
|
||||
description: "Watch your favorite Porn on Beeg.com".into(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=beeg.com".into(),
|
||||
status: "active".into(),
|
||||
categories: vec![],
|
||||
options: vec![
|
||||
ChannelOption {
|
||||
id: "sites".into(),
|
||||
title: "Sites".into(),
|
||||
description: "Filter for different Sites".into(),
|
||||
systemImage: "rectangle.stack".into(),
|
||||
colorName: "green".into(),
|
||||
options: self.sites.read().map(|v| v.clone()).unwrap_or_default(),
|
||||
multiSelect: false,
|
||||
},
|
||||
ChannelOption {
|
||||
id: "categories".into(),
|
||||
title: "Categories".into(),
|
||||
description: "Filter for different Networks".into(),
|
||||
systemImage: "list.dash".into(),
|
||||
colorName: "purple".into(),
|
||||
options: self.categories.read().map(|v| v.clone()).unwrap_or_default(),
|
||||
multiSelect: false,
|
||||
},
|
||||
ChannelOption {
|
||||
id: "stars".into(),
|
||||
title: "Stars".into(),
|
||||
description: "Filter for different Pornstars".into(),
|
||||
systemImage: "star.fill".into(),
|
||||
colorName: "yellow".into(),
|
||||
options: self.stars.read().map(|v| v.clone()).unwrap_or_default(),
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
nsfw: true,
|
||||
cacheDuration: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let mut slug = "";
|
||||
if options.categories.is_some()
|
||||
&& !options.categories.as_ref().unwrap().is_empty()
|
||||
&& options.categories.as_ref().unwrap() != "all"
|
||||
{
|
||||
slug = options.categories.as_ref().unwrap();
|
||||
}
|
||||
if options.sites.is_some()
|
||||
&& !options.sites.as_ref().unwrap().is_empty()
|
||||
&& options.sites.as_ref().unwrap() != "all"
|
||||
{
|
||||
slug = options.sites.as_ref().unwrap();
|
||||
}
|
||||
if options.stars.is_some()
|
||||
&& !options.stars.as_ref().unwrap().is_empty()
|
||||
&& options.stars.as_ref().unwrap() != "all"
|
||||
{
|
||||
slug = options.stars.as_ref().unwrap();
|
||||
}
|
||||
let video_url = format!(
|
||||
"https://store.externulls.com/facts/tag?limit=100&offset={}{}",
|
||||
page - 1, match slug {
|
||||
"" => "&id=27173".to_string(),
|
||||
_ => format!("&slug={}", slug.replace(" ", "")),
|
||||
}
|
||||
);
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
items.clone()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
let text = requester.get(&video_url, None).await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_str::<serde_json::Value>(&text).unwrap();
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(json.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
async fn query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
query: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let video_url = format!(
|
||||
"https://store.externulls.com/facts/tag?get_original=true&limit=100&offset={}&slug={}",
|
||||
page - 1,
|
||||
query.replace(" ", ""),
|
||||
);
|
||||
// Check our Video Cache. If the result is younger than 1 hour, we return it.
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
let _ = cache.check().await;
|
||||
return Ok(items.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
|
||||
let text = requester.get(&video_url, None).await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_str::<serde_json::Value>(&text).unwrap();
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(json.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
fn get_video_items_from_html(&self, json: Value) -> Vec<VideoItem> {
|
||||
let mut items = Vec::new();
|
||||
let array = match json.as_array() {
|
||||
Some(a) => a,
|
||||
None => return items,
|
||||
};
|
||||
|
||||
for video in array {
|
||||
let file = match video.get("file") { Some(v) => v, None => continue };
|
||||
let hls = match file.get("hls_resources") { Some(v) => v, None => continue };
|
||||
let key = match hls.get("fl_cdn_multi").and_then(|v| v.as_str()) {
|
||||
Some(v) => v,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let id = file.get("id").and_then(|v| v.as_i64()).unwrap_or(0).to_string();
|
||||
let title = file
|
||||
.get("data")
|
||||
.and_then(|v| v.get(0))
|
||||
.and_then(|v| v.get("cd_value"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| decode(s.as_bytes()).to_string().unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
|
||||
let duration = file
|
||||
.get("fl_duration")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
|
||||
let views = video
|
||||
.get("fc_facts")
|
||||
.and_then(|v| v.get(0))
|
||||
.and_then(|v| v.get("fc_st_views"))
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| parse_abbreviated_number(s))
|
||||
.unwrap_or(0);
|
||||
|
||||
let thumb = format!("https://thumbs.externulls.com/videos/{}/0.webp?size=480x270", id);
|
||||
|
||||
let mut item = VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
format!("https://video.externulls.com/{}", key),
|
||||
"beeg".into(),
|
||||
thumb,
|
||||
duration as u32,
|
||||
);
|
||||
|
||||
if views > 0 {
|
||||
item = item.views(views);
|
||||
}
|
||||
|
||||
items.push(item);
|
||||
}
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for BeegProvider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
_: DbPool,
|
||||
_: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
_: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let page = page.parse::<u8>().unwrap_or(1);
|
||||
let result = match query {
|
||||
Some(q) => self.query(cache, page, &q, options).await,
|
||||
None => self.get(cache, page, options).await,
|
||||
};
|
||||
|
||||
result.unwrap_or_else(|e| {
|
||||
eprintln!("beeg provider error: {}", e);
|
||||
vec![]
|
||||
})
|
||||
}
|
||||
|
||||
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
||||
Some(self.build_channel(clientversion))
|
||||
}
|
||||
}
|
||||
202
src/providers/freshporno.rs
Normal file
@@ -0,0 +1,202 @@
|
||||
use crate::api::ClientVersion;
|
||||
use crate::status::Channel;
|
||||
use crate::DbPool;
|
||||
use crate::providers::Provider;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::videos::{ServerOptions, VideoItem};
|
||||
use async_trait::async_trait;
|
||||
use error_chain::error_chain;
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
use std::vec;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FreshpornoProvider {
|
||||
url: String,
|
||||
}
|
||||
|
||||
impl FreshpornoProvider {
|
||||
pub fn new() -> Self {
|
||||
FreshpornoProvider {
|
||||
url: "https://freshporno.net".to_string(),
|
||||
}
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
sort: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let sort_string = match sort {
|
||||
"popular" => "/most-popular",
|
||||
"top-rated" => "/top-rated",
|
||||
_ => "",
|
||||
};
|
||||
let video_url = format!("{}{}/{}/", self.url, sort_string, page);
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
items.clone()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
|
||||
let text = requester.get(&video_url, None).await.unwrap();
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
async fn query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
query: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let search_string = query.to_lowercase().trim().replace(" ", "-");
|
||||
let video_url = format!("{}/search/{}/{}/", self.url, search_string, page);
|
||||
// Check our Video Cache. If the result is younger than 1 hour, we return it.
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
let _ = cache.check().await;
|
||||
return Ok(items.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
|
||||
let text = requester.get(&video_url, None).await.unwrap();
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
fn get_video_items_from_html(&self, html: String) -> Vec<VideoItem> {
|
||||
if html.is_empty() {
|
||||
println!("HTML is empty");
|
||||
return vec![];
|
||||
}
|
||||
let mut items: Vec<VideoItem> = Vec::new();
|
||||
let raw_videos = html.split("class=\"pagination\"").collect::<Vec<&str>>()[0]
|
||||
.split("class=\"page-content item\"")
|
||||
.collect::<Vec<&str>>()[1..]
|
||||
.to_vec();
|
||||
for video_segment in &raw_videos {
|
||||
// let vid = video_segment.split("\n").collect::<Vec<&str>>();
|
||||
// for (index, line) in vid.iter().enumerate() {
|
||||
// println!("Line {}: {}", index, line);
|
||||
// }
|
||||
let video_url: String = video_segment.split("<a href=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0].to_string();
|
||||
let mut title = video_segment.split("\" title=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
// html decode
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
let id = video_url.split("/").collect::<Vec<&str>>()[4].to_string();
|
||||
|
||||
let thumb = video_segment.split("<img class=\"lazy-load").collect::<Vec<&str>>()[1]
|
||||
.split("data-original=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
|
||||
let preview = video_segment
|
||||
.split("data-preview=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
|
||||
let video_item = VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
video_url.to_string(),
|
||||
"freshporno".to_string(),
|
||||
thumb,
|
||||
0,
|
||||
)
|
||||
.preview(preview)
|
||||
;
|
||||
items.push(video_item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for FreshpornoProvider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
pool: DbPool,
|
||||
sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
per_page: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let _ = per_page;
|
||||
let _ = pool;
|
||||
let videos: std::result::Result<Vec<VideoItem>, Error> = match query {
|
||||
Some(q) => {
|
||||
self.query(cache, page.parse::<u8>().unwrap_or(1), &q,options)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
self.get(cache, page.parse::<u8>().unwrap_or(1), &sort, options)
|
||||
.await
|
||||
}
|
||||
};
|
||||
match videos {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
println!("Error fetching videos: {}", e);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_channel(&self,clientversion:ClientVersion) -> Option<Channel> {
|
||||
println!("Getting channel for placeholder with client version: {:?}",clientversion);
|
||||
let _ = clientversion;
|
||||
Some(Channel {
|
||||
id:"placeholder".to_string(),name:"PLACEHOLDER".to_string(),description:"PLACEHOLDER FOR PARENT CLASS".to_string(),premium:false,favicon:"https://www.google.com/s2/favicons?sz=64&domain=missav.ws".to_string(),status:"active".to_string(),categories:vec![],options:vec![],nsfw:true,cacheDuration:None,
|
||||
})
|
||||
}
|
||||
}
|
||||
269
src/providers/hanime.rs
Normal file
@@ -0,0 +1,269 @@
|
||||
use std::vec;
|
||||
use async_trait::async_trait;
|
||||
use error_chain::error_chain;
|
||||
use futures::future::join_all;
|
||||
|
||||
use crate::db;
|
||||
use crate::providers::Provider;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::videos::{self, ServerOptions, VideoItem};
|
||||
use crate::DbPool;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
|
||||
struct HanimeSearchRequest{
|
||||
search_text: String,
|
||||
tags: Vec<String>,
|
||||
tags_mode: String,
|
||||
brands: Vec<String>,
|
||||
blacklist: Vec<String>,
|
||||
order_by: String,
|
||||
ordering: String,
|
||||
page: u8
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl HanimeSearchRequest {
|
||||
pub fn new() -> Self {
|
||||
HanimeSearchRequest {
|
||||
search_text: "".to_string(),
|
||||
tags: vec![],
|
||||
tags_mode: "AND".to_string(),
|
||||
brands: vec![],
|
||||
blacklist: vec![],
|
||||
order_by: "created_at_unix".to_string(),
|
||||
ordering: "desc".to_string(),
|
||||
page: 0
|
||||
}
|
||||
}
|
||||
pub fn tags(mut self, tags: Vec<String>) -> Self {
|
||||
self.tags = tags;
|
||||
self
|
||||
}
|
||||
pub fn search_text(mut self, search_text: String) -> Self {
|
||||
self.search_text = search_text;
|
||||
self
|
||||
}
|
||||
pub fn tags_mode(mut self, tags_mode: String) -> Self {
|
||||
self.tags_mode = tags_mode;
|
||||
self
|
||||
}
|
||||
pub fn brands(mut self, brands: Vec<String>) -> Self {
|
||||
self.brands = brands;
|
||||
self
|
||||
}
|
||||
pub fn blacklist(mut self, blacklist: Vec<String>) -> Self {
|
||||
self.blacklist = blacklist;
|
||||
self
|
||||
}
|
||||
pub fn order_by(mut self, order_by: String) -> Self {
|
||||
self.order_by = order_by;
|
||||
self
|
||||
}
|
||||
pub fn ordering(mut self, ordering: String) -> Self {
|
||||
self.ordering = ordering;
|
||||
self
|
||||
}
|
||||
pub fn page(mut self, page: u8) -> Self {
|
||||
self.page = page;
|
||||
self
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug)]
|
||||
struct HanimeSearchResponse{
|
||||
page: u8,
|
||||
nbPages:u8,
|
||||
nbHits: u32,
|
||||
hitsPerPage: u8,
|
||||
hits: String
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
|
||||
struct HanimeSearchResult{
|
||||
id: u64,
|
||||
name: String,
|
||||
titles: Vec<String>,
|
||||
slug: String,
|
||||
description: String,
|
||||
views: u64,
|
||||
interests: u64,
|
||||
poster_url: String,
|
||||
cover_url: String,
|
||||
brand: String,
|
||||
brand_id: u64,
|
||||
duration_in_ms: u32,
|
||||
is_censored: bool,
|
||||
rating: Option<u32>,
|
||||
likes: u64,
|
||||
dislikes: u64,
|
||||
downloads: u64,
|
||||
monthly_ranked: Option<u64>,
|
||||
tags: Vec<String>,
|
||||
created_at: u64,
|
||||
released_at: u64,
|
||||
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct HanimeProvider {
|
||||
url: String,
|
||||
}
|
||||
|
||||
impl HanimeProvider {
|
||||
pub fn new() -> Self {
|
||||
HanimeProvider {
|
||||
url: "https://hanime.tv/".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_video_item(&self, hit: HanimeSearchResult, pool: DbPool, options: ServerOptions) -> Result<VideoItem> {
|
||||
let mut conn = pool.get().expect("couldn't get db connection from pool");
|
||||
let db_result = db::get_video(&mut conn,format!("https://h.freeanimehentai.net/api/v8/video?id={}&", hit.slug.clone()));
|
||||
drop(conn);
|
||||
let id = hit.id.to_string();
|
||||
let title = hit.name;
|
||||
let thumb = hit.cover_url.replace("https://hanime-cdn.com", "https://hottub.spacemoehre.de/proxy/hanime-cdn");
|
||||
let duration = (hit.duration_in_ms / 1000) as u32; // Convert ms to seconds
|
||||
let channel = "hanime".to_string(); // Placeholder, adjust as needed
|
||||
match db_result {
|
||||
Ok(Some(video_url)) => {
|
||||
return Ok(VideoItem::new(id, title, video_url.clone(), channel, thumb, duration)
|
||||
.tags(hit.tags)
|
||||
.uploader(hit.brand)
|
||||
.views(hit.views as u32)
|
||||
.rating((hit.likes as f32 / (hit.likes + hit.dislikes)as f32) * 100 as f32)
|
||||
.aspect_ratio(0.68)
|
||||
.formats(vec![videos::VideoFormat::new(video_url.clone(), "1080".to_string(), "m3u8".to_string())]));
|
||||
}
|
||||
Ok(None) => (),
|
||||
Err(e) => {
|
||||
println!("Error fetching video from database: {}", e);
|
||||
// return Err(format!("Error fetching video from database: {}", e).into());
|
||||
}
|
||||
}
|
||||
let url = format!("https://h.freeanimehentai.net/api/v8/video?id={}&", hit.slug);
|
||||
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
let text = requester.get(&url, None).await.unwrap();
|
||||
|
||||
let urls = text.split("\"servers\"").collect::<Vec<&str>>()[1];
|
||||
let mut url_vec = vec![];
|
||||
|
||||
for el in urls.split("\"url\":\"").collect::<Vec<&str>>(){
|
||||
let url = el.split("\"").collect::<Vec<&str>>()[0];
|
||||
if !url.is_empty() && url.contains("m3u8") {
|
||||
url_vec.push(url.to_string());
|
||||
}
|
||||
}
|
||||
let mut conn = pool.get().expect("couldn't get db connection from pool");
|
||||
let _ = db::insert_video(&mut conn, &format!("https://h.freeanimehentai.net/api/v8/video?id={}&", hit.slug.clone()), &url_vec[0].clone());
|
||||
drop(conn);
|
||||
Ok(VideoItem::new(id, title, url_vec[0].clone(), channel, thumb, duration)
|
||||
.tags(hit.tags)
|
||||
.uploader(hit.brand)
|
||||
.views(hit.views as u32)
|
||||
.rating((hit.likes as f32 / (hit.likes + hit.dislikes)as f32) * 100 as f32)
|
||||
.formats(vec![videos::VideoFormat::new(url_vec[0].clone(), "1080".to_string(), "m3u8".to_string())]))
|
||||
|
||||
}
|
||||
|
||||
async fn get(&self, cache: VideoCache, pool: DbPool, page: u8, query: String, sort:String, options: ServerOptions) -> Result<Vec<VideoItem>> {
|
||||
let index = format!("hanime:{}:{}:{}", query, page, sort);
|
||||
let order_by = match sort.contains("."){
|
||||
true => sort.split(".").collect::<Vec<&str>>()[0].to_string(),
|
||||
false => "created_at_unix".to_string(),
|
||||
};
|
||||
let ordering = match sort.contains("."){
|
||||
true => sort.split(".").collect::<Vec<&str>>()[1].to_string(),
|
||||
false => "desc".to_string(),
|
||||
};
|
||||
let old_items = match cache.get(&index) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 60 * 12 {
|
||||
//println!("Cache hit for URL: {}", index);
|
||||
return Ok(items.clone());
|
||||
}
|
||||
else{
|
||||
items.clone()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let search = HanimeSearchRequest::new()
|
||||
.page(page-1)
|
||||
.search_text(query.clone())
|
||||
.order_by(order_by)
|
||||
.ordering(ordering);
|
||||
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
let response = requester.post_json("https://search.htv-services.com/search", &search, vec![]).await.unwrap();
|
||||
|
||||
|
||||
|
||||
let hits = match response.json::<HanimeSearchResponse>().await {
|
||||
Ok(resp) => resp.hits,
|
||||
Err(e) => {
|
||||
println!("Failed to parse HanimeSearchResponse: {}", e);
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
let hits_json: Vec<HanimeSearchResult> = serde_json::from_str(hits.as_str())
|
||||
.map_err(|e| format!("Failed to parse hits JSON: {}", e))?;
|
||||
// let timeout_duration = Duration::from_secs(120);
|
||||
let futures = hits_json.into_iter().map(|el| self.get_video_item(el.clone(), pool.clone(), options.clone()));
|
||||
let results: Vec<Result<VideoItem>> = join_all(futures).await;
|
||||
let video_items: Vec<VideoItem> = results
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
.collect();
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&index);
|
||||
cache.insert(index.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
|
||||
Ok(video_items)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for HanimeProvider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
pool: DbPool,
|
||||
sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
per_page: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let _ = options;
|
||||
let _ = per_page;
|
||||
let _ = sort;
|
||||
let videos: std::result::Result<Vec<VideoItem>, Error> = match query {
|
||||
Some(q) => self.get(cache, pool, page.parse::<u8>().unwrap_or(1), q, sort, options).await,
|
||||
None => self.get(cache, pool, page.parse::<u8>().unwrap_or(1), "".to_string(), sort, options).await,
|
||||
};
|
||||
match videos {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
println!("Error fetching videos: {}", e);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
279
src/providers/homoxxx.rs
Normal file
@@ -0,0 +1,279 @@
|
||||
use crate::DbPool;
|
||||
use crate::providers::Provider;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::flaresolverr::{FlareSolverrRequest, Flaresolverr};
|
||||
use crate::util::time::parse_time_to_seconds;
|
||||
use crate::videos::{ServerOptions, VideoItem};
|
||||
use async_trait::async_trait;
|
||||
use error_chain::error_chain;
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
use std::env;
|
||||
use std::vec;
|
||||
use wreq::{Client};
|
||||
use wreq_util::Emulation;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HomoxxxProvider {
|
||||
url: String,
|
||||
}
|
||||
impl HomoxxxProvider {
|
||||
pub fn new() -> Self {
|
||||
HomoxxxProvider {
|
||||
url: "https://homo.xxx".to_string(),
|
||||
}
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
sort: &str,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let sort_string = match sort {
|
||||
"trending" => "/trending",
|
||||
"popular" => "/popular",
|
||||
_ => "/new",
|
||||
};
|
||||
let video_url = format!("{}{}/{}/", self.url, sort_string, page);
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
items.clone()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
// let proxy = Proxy::all("http://192.168.0.103:8081").unwrap();
|
||||
let client = Client::builder().cert_verification(false).emulation(Emulation::Firefox136).build()?;
|
||||
|
||||
let mut response = client.get(video_url.clone())
|
||||
// .proxy(proxy.clone())
|
||||
.send().await?;
|
||||
if response.status().is_redirection(){
|
||||
println!("Redirection detected, following to: {}", response.headers()["Location"].to_str().unwrap());
|
||||
response = client.get(response.headers()["Location"].to_str().unwrap())
|
||||
// .proxy(proxy.clone())
|
||||
.send().await?;
|
||||
}
|
||||
if response.status().is_success() {
|
||||
let text = response.text().await?;
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
} else {
|
||||
let flare_url = env::var("FLARE_URL").expect("FLARE_URL not set");
|
||||
let flare = Flaresolverr::new(flare_url);
|
||||
let result = flare
|
||||
.solve(FlareSolverrRequest {
|
||||
cmd: "request.get".to_string(),
|
||||
url: video_url.clone(),
|
||||
maxTimeout: 60000,
|
||||
})
|
||||
.await;
|
||||
let video_items = match result {
|
||||
Ok(res) => {
|
||||
// println!("FlareSolverr response: {}", res);
|
||||
self.get_video_items_from_html(res.solution.response)
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Error solving FlareSolverr: {}", e);
|
||||
return Err("Failed to solve FlareSolverr".into());
|
||||
}
|
||||
};
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
}
|
||||
async fn query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
query: &str,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let search_string = query.to_lowercase().trim().replace(" ", "-");
|
||||
let mut video_url = format!("{}/search/{}/{}/", self.url, search_string, page);
|
||||
|
||||
if search_string.starts_with("@"){
|
||||
let url_part = search_string.split("@").collect::<Vec<&str>>()[1].replace(":", "/");
|
||||
video_url = format!("{}/{}/", self.url, url_part);
|
||||
}
|
||||
// Check our Video Cache. If the result is younger than 1 hour, we return it.
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
let _ = cache.check().await;
|
||||
return Ok(items.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
// let proxy = Proxy::all("http://192.168.0.103:8081").unwrap();
|
||||
let client = Client::builder().cert_verification(false).emulation(Emulation::Firefox136).build()?;
|
||||
|
||||
let mut response = client.get(video_url.clone())
|
||||
// .proxy(proxy.clone())
|
||||
.send().await?;
|
||||
|
||||
if response.status().is_redirection(){
|
||||
|
||||
response = client.get(self.url.clone() + response.headers()["Location"].to_str().unwrap())
|
||||
// .proxy(proxy.clone())
|
||||
.send().await?;
|
||||
}
|
||||
|
||||
if response.status().is_success() {
|
||||
let text = response.text().await?;
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
} else {
|
||||
let flare_url = env::var("FLARE_URL").expect("FLARE_URL not set");
|
||||
let flare = Flaresolverr::new(flare_url);
|
||||
let result = flare
|
||||
.solve(FlareSolverrRequest {
|
||||
cmd: "request.get".to_string(),
|
||||
url: video_url.clone(),
|
||||
maxTimeout: 60000,
|
||||
})
|
||||
.await;
|
||||
let video_items = match result {
|
||||
Ok(res) => self.get_video_items_from_html(res.solution.response),
|
||||
Err(e) => {
|
||||
println!("Error solving FlareSolverr: {}", e);
|
||||
return Err("Failed to solve FlareSolverr".into());
|
||||
}
|
||||
};
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_video_items_from_html(&self, html: String) -> Vec<VideoItem> {
|
||||
if html.is_empty() {
|
||||
println!("HTML is empty");
|
||||
return vec![];
|
||||
}
|
||||
let mut items: Vec<VideoItem> = Vec::new();
|
||||
let raw_videos = html.split("pagination").collect::<Vec<&str>>()[0]
|
||||
.split("<div class=\"item \">")
|
||||
.collect::<Vec<&str>>()[1..]
|
||||
.to_vec();
|
||||
for video_segment in &raw_videos {
|
||||
// let vid = video_segment.split("\n").collect::<Vec<&str>>();
|
||||
// for (index, line) in vid.iter().enumerate() {
|
||||
// println!("Line {}: {}", index, line);
|
||||
// }
|
||||
let video_url: String = video_segment.split("<a href=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0].to_string();
|
||||
let preview_url = video_segment.split("data-preview-custom=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let mut title = video_segment.split("\" title=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
// html decode
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
let id = video_url.split("/").collect::<Vec<&str>>()[4].to_string();
|
||||
let raw_duration = video_segment
|
||||
.split("<p class=\"duration_item\">").collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let duration = parse_time_to_seconds(&raw_duration).unwrap_or(0) as u32;
|
||||
|
||||
let thumb = video_segment.split("thumb lazyload").collect::<Vec<&str>>()[1]
|
||||
.split("data-src=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let video_item = VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
video_url.to_string(),
|
||||
"homoxxx".to_string(),
|
||||
thumb,
|
||||
duration,
|
||||
)
|
||||
.preview(preview_url)
|
||||
;
|
||||
items.push(video_item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for HomoxxxProvider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
pool: DbPool,
|
||||
sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
per_page: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let _ = options;
|
||||
let _ = per_page;
|
||||
let _ = pool;
|
||||
let videos: std::result::Result<Vec<VideoItem>, Error> = match query {
|
||||
Some(q) => {
|
||||
self.query(cache, page.parse::<u8>().unwrap_or(1), &q,)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
self.get(cache, page.parse::<u8>().unwrap_or(1), &sort)
|
||||
.await
|
||||
}
|
||||
};
|
||||
match videos {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
println!("Error fetching videos: {}", e);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
483
src/providers/hqporner.rs
Normal file
@@ -0,0 +1,483 @@
|
||||
use crate::DbPool;
|
||||
use crate::api::ClientVersion;
|
||||
use crate::providers::Provider;
|
||||
use crate::status::*;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::discord::{format_error_chain, send_discord_error_report};
|
||||
use crate::util::requester::Requester;
|
||||
use crate::util::time::parse_time_to_seconds;
|
||||
use crate::videos::{ServerOptions, VideoFormat, VideoItem};
|
||||
use async_trait::async_trait;
|
||||
use error_chain::error_chain;
|
||||
use futures::future::join_all;
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::thread::sleep;
|
||||
use std::{thread, vec};
|
||||
use titlecase::Titlecase;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
Json(serde_json::Error);
|
||||
}
|
||||
errors {
|
||||
Parse(msg: String) {
|
||||
description("parse error")
|
||||
display("parse error: {}", msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HqpornerProvider {
|
||||
url: String,
|
||||
stars: Arc<RwLock<Vec<FilterOption>>>,
|
||||
categories: Arc<RwLock<Vec<FilterOption>>>,
|
||||
}
|
||||
|
||||
impl HqpornerProvider {
|
||||
pub fn new() -> Self {
|
||||
let provider = HqpornerProvider {
|
||||
url: "https://hqporner.com".to_string(),
|
||||
stars: Arc::new(RwLock::new(vec![])),
|
||||
categories: Arc::new(RwLock::new(vec![])),
|
||||
};
|
||||
provider.spawn_initial_load();
|
||||
provider
|
||||
}
|
||||
|
||||
fn spawn_initial_load(&self) {
|
||||
let url = self.url.clone();
|
||||
let stars = Arc::clone(&self.stars);
|
||||
let categories = Arc::clone(&self.categories);
|
||||
|
||||
thread::spawn(move || {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build();
|
||||
|
||||
if let Ok(runtime) = rt {
|
||||
runtime.block_on(async move {
|
||||
if let Err(e) = Self::load_stars(&url, stars).await {
|
||||
eprintln!("load_stars failed: {e}");
|
||||
}
|
||||
if let Err(e) = Self::load_categories(&url, categories).await {
|
||||
eprintln!("load_categories failed: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn load_stars(base_url: &str, stars: Arc<RwLock<Vec<FilterOption>>>) -> Result<()> {
|
||||
let mut requester = Requester::new();
|
||||
let text = requester
|
||||
.get(&format!("{}/girls", base_url), None)
|
||||
.await
|
||||
.map_err(|e| Error::from(format!("Request failed: {}", e)))?;
|
||||
|
||||
let stars_div = text
|
||||
.split("<span>Girls</span>")
|
||||
.last()
|
||||
.and_then(|s| s.split("</ul>").next())
|
||||
.ok_or_else(|| Error::from("Could not find stars div"))?;
|
||||
|
||||
for stars_element in stars_div.split("<li ").skip(1) {
|
||||
let star_id = stars_element
|
||||
.split("href=\"/actress/")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let star_name = stars_element
|
||||
.split("<a ")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('>').nth(1))
|
||||
.and_then(|s| s.split('<').next())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
if let (Some(id), Some(name)) = (star_id, star_name) {
|
||||
Self::push_unique(&stars, FilterOption { id, title: name });
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_categories(
|
||||
base_url: &str,
|
||||
categories: Arc<RwLock<Vec<FilterOption>>>,
|
||||
) -> Result<()> {
|
||||
let mut requester = Requester::new();
|
||||
let text = requester
|
||||
.get(&format!("{}/categories", base_url), None)
|
||||
.await
|
||||
.map_err(|e| Error::from(format!("Request failed: {}", e)))?;
|
||||
|
||||
let categories_div = text
|
||||
.split("<span>Categories</span>")
|
||||
.last()
|
||||
.and_then(|s| s.split("</ul>").next())
|
||||
.ok_or_else(|| Error::from("Could not find categories div"))?;
|
||||
|
||||
for categories_element in categories_div.split("<li ").skip(1) {
|
||||
let category_id = categories_element
|
||||
.split("href=\"/category/")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let category_name = categories_element
|
||||
.split("<a ")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('>').nth(1))
|
||||
.and_then(|s| s.split('<').next())
|
||||
.map(|s| s.titlecase());
|
||||
|
||||
if let (Some(id), Some(name)) = (category_id, category_name) {
|
||||
Self::push_unique(&categories, FilterOption { id, title: name });
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
|
||||
Channel {
|
||||
id: "hqporner".to_string(),
|
||||
name: "HQPorner".to_string(),
|
||||
description: "HD Porn Videos Tube".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=hqporner.com".to_string(),
|
||||
status: "active".to_string(),
|
||||
categories: self
|
||||
.categories
|
||||
.read()
|
||||
.map(|c| c.iter().map(|o| o.title.clone()).collect())
|
||||
.unwrap_or_default(),
|
||||
options: vec![],
|
||||
nsfw: true,
|
||||
cacheDuration: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn push_unique(target: &Arc<RwLock<Vec<FilterOption>>>, item: FilterOption) {
|
||||
if let Ok(mut vec) = target.write() {
|
||||
if !vec.iter().any(|x| x.id == item.id) {
|
||||
vec.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
_sort: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let video_url = format!("{}/hdporn/{}", self.url, page);
|
||||
if let Some((time, items)) = cache.get(&video_url) {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 300 {
|
||||
return Ok(items.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let mut requester = options.requester.clone().ok_or("No requester")?;
|
||||
let text = requester
|
||||
.get(&video_url, None)
|
||||
.await
|
||||
.map_err(|e| Error::from(format!("Request failed: {}", e)))?;
|
||||
|
||||
let video_items = self.get_video_items_from_html(text, &mut requester).await;
|
||||
if !video_items.is_empty() {
|
||||
cache.insert(video_url, video_items.clone());
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
async fn query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
query: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let search_string = query.trim().to_lowercase();
|
||||
let mut video_url = format!("{}/?q={}&p={}", self.url, search_string, page);
|
||||
|
||||
if let Ok(stars) = self.stars.read() {
|
||||
if let Some(star) = stars
|
||||
.iter()
|
||||
.find(|s| s.title.to_lowercase() == search_string)
|
||||
{
|
||||
video_url = format!("{}/actress/{}/{}", self.url, star.id, page);
|
||||
}
|
||||
}
|
||||
if let Ok(cats) = self.categories.read() {
|
||||
if let Some(cat) = cats
|
||||
.iter()
|
||||
.find(|c| c.title.to_lowercase() == search_string)
|
||||
{
|
||||
video_url = format!("{}/category/{}/{}", self.url, cat.id, page);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((time, items)) = cache.get(&video_url) {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 300 {
|
||||
return Ok(items.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let mut requester = options.requester.clone().ok_or("No requester")?;
|
||||
let text = requester
|
||||
.get(&video_url, None)
|
||||
.await
|
||||
.map_err(|e| Error::from(format!("Request failed: {}", e)))?;
|
||||
|
||||
let video_items = self.get_video_items_from_html(text, &mut requester).await;
|
||||
if !video_items.is_empty() {
|
||||
cache.insert(video_url, video_items.clone());
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
async fn get_video_items_from_html(
|
||||
&self,
|
||||
html: String,
|
||||
requester: &mut Requester,
|
||||
) -> Vec<VideoItem> {
|
||||
if html.is_empty() || html.contains("404 Not Found") {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let raw_videos: Vec<String> = html
|
||||
.split("id=\"footer\"")
|
||||
.next()
|
||||
.and_then(|s| s.split("<section class=\"box features\">").nth(2))
|
||||
.map(|s| {
|
||||
s.split("<section class=\"box feature\">")
|
||||
.skip(1)
|
||||
.map(|v| v.to_string())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let futures = raw_videos
|
||||
.into_iter()
|
||||
.map(|el| self.get_video_item(el, requester.clone()));
|
||||
|
||||
join_all(futures)
|
||||
.await
|
||||
.into_iter()
|
||||
.inspect(|r| {
|
||||
if let Err(e) = r {
|
||||
let msg = e.to_string();
|
||||
let chain = format_error_chain(e);
|
||||
tokio::spawn(async move {
|
||||
let _ = send_discord_error_report(
|
||||
msg,
|
||||
Some(chain),
|
||||
Some("Hqporner Provider"),
|
||||
None,
|
||||
file!(),
|
||||
line!(),
|
||||
module_path!(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
})
|
||||
.filter_map(Result::ok)
|
||||
.filter(|item| !item.formats.clone().unwrap().is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn get_video_item(&self, seg: String, mut requester: Requester) -> Result<VideoItem> {
|
||||
let video_url = format!(
|
||||
"{}{}",
|
||||
self.url,
|
||||
seg.split("<a href=\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
.ok_or_else(|| ErrorKind::Parse(format!("url \n{seg}").into()))?
|
||||
);
|
||||
let title_raw = seg
|
||||
.split("<h3 class=\"meta-data-title\">")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('>').nth(1))
|
||||
.and_then(|s| s.split('<').next())
|
||||
.ok_or_else(|| ErrorKind::Parse(format!("title \n{seg}").into()))?;
|
||||
let title = decode(title_raw.as_bytes())
|
||||
.to_string()
|
||||
.unwrap_or_else(|_| title_raw.to_string())
|
||||
.titlecase();
|
||||
|
||||
let id = video_url
|
||||
.split('/')
|
||||
.nth(4)
|
||||
.and_then(|s| s.split('.').next())
|
||||
.ok_or_else(|| ErrorKind::Parse(format!("id \n{seg}").into()))?
|
||||
.to_string();
|
||||
let thumb = format!(
|
||||
"https:{}",
|
||||
seg.split("onmouseleave='defaultImage(\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
.ok_or_else(|| ErrorKind::Parse(format!("thumb \n{seg}").into()))?
|
||||
);
|
||||
let raw_duration = seg
|
||||
.split("<span class=\"icon fa-clock-o meta-data\">")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split("s<").next())
|
||||
.map(|s| s.replace("m ", ":"))
|
||||
.unwrap_or_default();
|
||||
let duration = parse_time_to_seconds(&raw_duration).unwrap_or(0) as u32;
|
||||
|
||||
let (tags, formats) = self.extract_media(&video_url, &mut requester).await?;
|
||||
|
||||
Ok(
|
||||
VideoItem::new(id, title, video_url, "hqporner".into(), thumb, duration)
|
||||
.formats(formats)
|
||||
.tags(tags),
|
||||
)
|
||||
}
|
||||
|
||||
async fn extract_media(
|
||||
&self,
|
||||
url: &str,
|
||||
requester: &mut Requester,
|
||||
) -> Result<(Vec<String>, Vec<VideoFormat>)> {
|
||||
let mut formats = vec![];
|
||||
let mut tags = vec![];
|
||||
let resp = requester
|
||||
.get_raw_with_headers(
|
||||
url,
|
||||
vec![("Referer".to_string(), "https://hqporner.com/".into())],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| Error::from(format!("Request failed: {}", e)))?;
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| Error::from(format!("Text conversion failed: {}", e)))?;
|
||||
|
||||
if text.contains("Why do I see it?") {
|
||||
return Ok((tags, formats));
|
||||
}
|
||||
|
||||
// Extract Stars & Tags
|
||||
if let Some(stars_block) = text
|
||||
.split("icon fa-star-o")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split("</li>").next())
|
||||
{
|
||||
for star_el in stars_block.split("href=\"/actress/").skip(1) {
|
||||
let id = star_el.split('"').next().unwrap_or("").to_string();
|
||||
let name = star_el
|
||||
.split("\">")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('<').next())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if !name.is_empty() {
|
||||
tags.push(name.clone());
|
||||
Self::push_unique(&self.stars, FilterOption { id, title: name });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Player / Video Extraction
|
||||
let player_url = format!(
|
||||
"https:{}",
|
||||
text.split("url: '/blocks/altplayer.php?i=")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('\'').next())
|
||||
.ok_or("No player link")?
|
||||
);
|
||||
let mut r = requester
|
||||
.get_raw_with_headers(
|
||||
&player_url,
|
||||
vec![("Referer".to_string(), "https://hqporner.com/".into())],
|
||||
).await;
|
||||
|
||||
if let Err(_e) = &r {
|
||||
sleep(std::time::Duration::from_secs(1));
|
||||
r = requester
|
||||
.get_raw_with_headers(
|
||||
&player_url,
|
||||
vec![("Referer".to_string(), "https://hqporner.com/".into())],
|
||||
).await;
|
||||
}
|
||||
let text2 = r
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| Error::from(format!("Text conversion failed: {}", e)))?;
|
||||
|
||||
// Check for error response
|
||||
if text2.starts_with("ERR:"){
|
||||
return Ok((tags, formats));
|
||||
}
|
||||
|
||||
let video_element = text2
|
||||
.split("<video ")
|
||||
.nth(2)
|
||||
.and_then(|s| s.split("</video>").next())
|
||||
.ok_or(format!("No video element\n{player_url}\n{text2}"))?;
|
||||
for source in video_element.split("<source ").skip(1) {
|
||||
let title = source
|
||||
.split("title=\\\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split("\\\"").next())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let quality = title.split(' ').next().unwrap_or("HD").to_string();
|
||||
let media_url = format!(
|
||||
"https:{}",
|
||||
source
|
||||
.split("src=\\\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split("\\\"").next())
|
||||
.unwrap_or("")
|
||||
);
|
||||
|
||||
formats.push(
|
||||
VideoFormat::new(media_url, quality, "mp4".into())
|
||||
.format_id(title.clone())
|
||||
.format_note(title),
|
||||
);
|
||||
}
|
||||
|
||||
Ok((tags, formats))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for HqpornerProvider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
_pool: DbPool,
|
||||
sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
_per_page: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let page_num = page.parse::<u8>().unwrap_or(1);
|
||||
let res = match query {
|
||||
Some(q) => self.query(cache, page_num, &q, options).await,
|
||||
None => self.get(cache, page_num, &sort, options).await,
|
||||
};
|
||||
res.unwrap_or_else(|e| {
|
||||
eprintln!("Hqporner error: {e}");
|
||||
let _ = send_discord_error_report(e.to_string(), Some(format_error_chain(&e)), None, None, file!(), line!(), module_path!());
|
||||
vec![]
|
||||
})
|
||||
}
|
||||
|
||||
fn get_channel(&self, v: ClientVersion) -> Option<Channel> {
|
||||
Some(self.build_channel(v))
|
||||
}
|
||||
}
|
||||
433
src/providers/hypnotube.rs
Normal file
@@ -0,0 +1,433 @@
|
||||
use crate::DbPool;
|
||||
use crate::api::ClientVersion;
|
||||
use crate::providers::Provider;
|
||||
use crate::status::*;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::discord::{format_error_chain, send_discord_error_report};
|
||||
use crate::util::requester::Requester;
|
||||
use crate::util::time::parse_time_to_seconds;
|
||||
use crate::videos::{ServerOptions, VideoItem};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use error_chain::error_chain;
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::{thread, vec};
|
||||
use titlecase::Titlecase;
|
||||
use wreq::Version;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
Json(serde_json::Error);
|
||||
}
|
||||
errors {
|
||||
Parse(msg: String) {
|
||||
description("parse error")
|
||||
display("parse error: {}", msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HypnotubeProvider {
|
||||
url: String,
|
||||
categories: Arc<RwLock<Vec<FilterOption>>>,
|
||||
}
|
||||
|
||||
impl HypnotubeProvider {
|
||||
pub fn new() -> Self {
|
||||
let provider = Self {
|
||||
url: "https://hypnotube.com".to_string(),
|
||||
categories: Arc::new(RwLock::new(vec![])),
|
||||
};
|
||||
provider.spawn_initial_load();
|
||||
provider
|
||||
}
|
||||
|
||||
fn spawn_initial_load(&self) {
|
||||
let url = self.url.clone();
|
||||
let categories = Arc::clone(&self.categories);
|
||||
|
||||
thread::spawn(async move || {
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
eprintln!("tokio runtime failed: {e}");
|
||||
send_discord_error_report(
|
||||
e.to_string(),
|
||||
Some(format_error_chain(&e)),
|
||||
Some("HypnoTube Provider"),
|
||||
Some("Failed to create tokio runtime"),
|
||||
file!(),
|
||||
line!(),
|
||||
module_path!(),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
rt.block_on(async {
|
||||
if let Err(e) = Self::load_categories(&url, Arc::clone(&categories)).await {
|
||||
eprintln!("load_categories failed: {e}");
|
||||
send_discord_error_report(
|
||||
e.to_string(),
|
||||
Some(format_error_chain(&e)),
|
||||
Some("HypnoTube Provider"),
|
||||
Some("Failed to load categories during initial load"),
|
||||
file!(),
|
||||
line!(),
|
||||
module_path!(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async fn load_categories(base: &str, cats: Arc<RwLock<Vec<FilterOption>>>) -> Result<()> {
|
||||
let mut requester = Requester::new();
|
||||
let text = requester
|
||||
.get(&format!("{base}/channels/"), Some(Version::HTTP_11))
|
||||
.await
|
||||
.map_err(|e| Error::from(format!("{}", e)))?;
|
||||
|
||||
let block = text
|
||||
.split(" title END ")
|
||||
.last()
|
||||
.ok_or_else(|| ErrorKind::Parse("categories block".into()))?
|
||||
.split(" main END ")
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
|
||||
for el in block.split("<!-- item -->").skip(1) {
|
||||
let id = el
|
||||
.split("<a href=\"https://hypnotube.com/channels/")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split("/\"").next())
|
||||
.ok_or_else(|| ErrorKind::Parse(format!("category id: {el}").into()))?
|
||||
.to_string();
|
||||
|
||||
let title = el
|
||||
.split("title=\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split("\"").next())
|
||||
.ok_or_else(|| ErrorKind::Parse(format!("category title: {el}").into()))?
|
||||
.titlecase();
|
||||
|
||||
Self::push_unique(&cats, FilterOption { id, title });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_channel(&self, clientversion: ClientVersion) -> Channel {
|
||||
let _ = clientversion;
|
||||
Channel {
|
||||
id: "hypnotube".to_string(),
|
||||
name: "Hypnotube".to_string(),
|
||||
description: "free video hypno tube for the sissy hypnosis porn fetish".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=hypnotube.com".to_string(),
|
||||
status: "active".to_string(),
|
||||
categories: self
|
||||
.categories
|
||||
.read()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| c.title.clone())
|
||||
.collect(),
|
||||
options: vec![ChannelOption {
|
||||
id: "sort".to_string(),
|
||||
title: "Sort".to_string(),
|
||||
description: "Sort the Videos".to_string(),
|
||||
systemImage: "list.number".to_string(),
|
||||
colorName: "blue".to_string(),
|
||||
options: vec![
|
||||
FilterOption {
|
||||
id: "most recent".into(),
|
||||
title: "Most Recent".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "most viewed".into(),
|
||||
title: "Most Viewed".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "top rated".into(),
|
||||
title: "Top Rated".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "longest".into(),
|
||||
title: "Longest".into(),
|
||||
},
|
||||
],
|
||||
multiSelect: false,
|
||||
}],
|
||||
nsfw: true,
|
||||
cacheDuration: Some(1800),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_unique(target: &Arc<RwLock<Vec<FilterOption>>>, item: FilterOption) {
|
||||
if let Ok(mut vec) = target.write() {
|
||||
if !vec.iter().any(|x| x.id == item.id) {
|
||||
vec.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
sort: &str,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let sort_string = match sort {
|
||||
"top rated" => "top-rated",
|
||||
"most viewed" => "most-viewed",
|
||||
"longest" => "longest",
|
||||
_ => "videos",
|
||||
};
|
||||
let video_url = format!("{}/{}/page{}.html", self.url, sort_string, page);
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return items.clone();
|
||||
} else {
|
||||
items.clone()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
let text = requester
|
||||
.get(&video_url, Some(Version::HTTP_11))
|
||||
.await
|
||||
.unwrap();
|
||||
if text.contains("Sorry, no results were found.") {
|
||||
return vec![];
|
||||
}
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone()).await;
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return old_items;
|
||||
}
|
||||
video_items
|
||||
}
|
||||
|
||||
async fn query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
query: &str,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let sort_string = match options.sort.as_deref().unwrap_or("") {
|
||||
"top rated" => "rating",
|
||||
"most viewed" => "views",
|
||||
"longest" => "longest",
|
||||
_ => "newest",
|
||||
};
|
||||
let video_url = format!(
|
||||
"{}/search/videos/{}/{}/page{}.html",
|
||||
self.url,
|
||||
query.trim().replace(" ", "%20"),
|
||||
sort_string,
|
||||
page
|
||||
);
|
||||
// Check our Video Cache. If the result is younger than 1 hour, we return it.
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return items.clone();
|
||||
} else {
|
||||
let _ = cache.check().await;
|
||||
return items.clone();
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
let text = match requester
|
||||
.post(
|
||||
format!("{}/searchgate.php", self.url).as_str(),
|
||||
format!("q={}&type=videos", query.replace(" ", "+")).as_str(),
|
||||
vec![("Content-Type", "application/x-www-form-urlencoded")],
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
{
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
eprint!("Hypnotube search POST request failed: {}", e);
|
||||
return vec![];
|
||||
}
|
||||
};
|
||||
// println!("Hypnotube search POST response status: {}", p.text().await.unwrap_or_default());
|
||||
// let text = requester.get(&video_url, Some(Version::HTTP_11)).await.unwrap();
|
||||
if text.contains("Sorry, no results were found.") {
|
||||
return vec![];
|
||||
}
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone()).await;
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return old_items;
|
||||
}
|
||||
video_items
|
||||
}
|
||||
|
||||
async fn get_video_items_from_html(&self, html: String) -> Vec<VideoItem> {
|
||||
if html.is_empty() || html.contains("404 Not Found") {
|
||||
eprint!("Hypnotube returned empty or 404 html");
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let block = match html
|
||||
.split("pagination-col col pagination")
|
||||
.next()
|
||||
.and_then(|s| s.split(" title END ").last())
|
||||
{
|
||||
Some(b) => b,
|
||||
None => {
|
||||
eprint!("Hypnotube Provider: Failed to get block from html");
|
||||
let e = Error::from(ErrorKind::Parse("html".into()));
|
||||
send_discord_error_report(
|
||||
e.to_string(),
|
||||
Some(format_error_chain(&e)),
|
||||
Some("Hypnotube Provider"),
|
||||
Some(&format!("Failed to get block from html:\n```{html}\n```")),
|
||||
file!(),
|
||||
line!(),
|
||||
module_path!(),
|
||||
)
|
||||
.await;
|
||||
return vec![];
|
||||
}
|
||||
};
|
||||
let mut items = vec![];
|
||||
for seg in block.split("<!-- item -->").skip(1) {
|
||||
let video_url = match seg
|
||||
.split(" href=\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
{
|
||||
Some(url) => url.to_string(),
|
||||
None => {
|
||||
eprint!("Hypnotube Provider: Failed to parse video url from segment");
|
||||
let e = Error::from(ErrorKind::Parse("video url".into()));
|
||||
send_discord_error_report(
|
||||
e.to_string(),
|
||||
Some(format_error_chain(&e)),
|
||||
Some("Hypnotube Provider"),
|
||||
Some(&format!(
|
||||
"Failed to parse video url from segment:\n```{seg}\n```"
|
||||
)),
|
||||
file!(),
|
||||
line!(),
|
||||
module_path!(),
|
||||
)
|
||||
.await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let mut title = seg
|
||||
.split(" title=\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
title = decode(title.clone().as_bytes())
|
||||
.to_string()
|
||||
.unwrap_or(title)
|
||||
.titlecase();
|
||||
let id = video_url
|
||||
.split('/')
|
||||
.nth(4)
|
||||
.and_then(|s| s.split('.').next())
|
||||
.ok_or_else(|| ErrorKind::Parse("video id".into()))
|
||||
.unwrap_or_else(|_| &title.as_str());
|
||||
let thumb = seg
|
||||
.split("<img ")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split("src=\"").nth(1))
|
||||
.and_then(|s| s.split("\"").next())
|
||||
.ok_or_else(|| ErrorKind::Parse("thumb block".into()))
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let raw_duration = seg
|
||||
.split("<span class=\"time\">")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('<').next())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let duration = parse_time_to_seconds(&raw_duration).unwrap_or(0) as u32;
|
||||
|
||||
let views = seg
|
||||
.split("<span class=\"icon i-eye\"></span>")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split("span class=\"sub-desc\">").nth(1))
|
||||
.and_then(|s| s.split("<").next())
|
||||
.unwrap_or("0")
|
||||
.replace(",", "")
|
||||
.parse::<u32>()
|
||||
.unwrap_or(0);
|
||||
let video_item = VideoItem::new(
|
||||
id.to_owned(),
|
||||
title,
|
||||
video_url,
|
||||
"hypnotube".into(),
|
||||
thumb,
|
||||
duration,
|
||||
)
|
||||
.views(views);
|
||||
items.push(video_item);
|
||||
}
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for HypnotubeProvider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
_pool: DbPool,
|
||||
sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
_per_page: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let page = page.parse::<u8>().unwrap_or(1);
|
||||
|
||||
let res = match query {
|
||||
Some(q) => self.to_owned().query(cache, page, &q, options).await,
|
||||
None => self.get(cache, page, &sort, options).await,
|
||||
};
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
fn get_channel(&self, v: ClientVersion) -> Option<Channel> {
|
||||
Some(self.build_channel(v))
|
||||
}
|
||||
}
|
||||
413
src/providers/javtiful.rs
Normal file
@@ -0,0 +1,413 @@
|
||||
use crate::DbPool;
|
||||
use crate::api::ClientVersion;
|
||||
use crate::providers::Provider;
|
||||
use crate::status::*;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::discord::{format_error_chain, send_discord_error_report};
|
||||
use crate::util::requester::Requester;
|
||||
use crate::util::time::parse_time_to_seconds;
|
||||
use crate::videos::{ServerOptions, VideoFormat, VideoItem};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use error_chain::error_chain;
|
||||
use futures::future::join_all;
|
||||
use htmlentity::entity::{decode, ICodedDataTrait};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::{vec};
|
||||
use titlecase::Titlecase;
|
||||
use wreq::Version;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
Json(serde_json::Error);
|
||||
}
|
||||
errors {
|
||||
Parse(msg: String) {
|
||||
description("parse error")
|
||||
display("parse error: {}", msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JavtifulProvider {
|
||||
url: String,
|
||||
categories: Arc<RwLock<Vec<FilterOption>>>,
|
||||
}
|
||||
|
||||
impl JavtifulProvider {
|
||||
pub fn new() -> Self {
|
||||
let provider = Self {
|
||||
url: "https://javtiful.com".to_string(),
|
||||
categories: Arc::new(RwLock::new(vec![])),
|
||||
};
|
||||
provider
|
||||
}
|
||||
|
||||
fn build_channel(&self, clientversion: ClientVersion) -> Channel {
|
||||
let _ = clientversion;
|
||||
Channel {
|
||||
id: "javtiful".to_string(),
|
||||
name: "Javtiful".to_string(),
|
||||
description: "Watch Porn!".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=javtiful.com".to_string(),
|
||||
status: "active".to_string(),
|
||||
categories: self
|
||||
.categories
|
||||
.read()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| c.title.clone())
|
||||
.collect(),
|
||||
options: vec![ChannelOption {
|
||||
id: "sort".to_string(),
|
||||
title: "Sort".to_string(),
|
||||
description: "Sort the Videos".to_string(),
|
||||
systemImage: "list.number".to_string(),
|
||||
colorName: "blue".to_string(),
|
||||
options: vec![
|
||||
FilterOption {
|
||||
id: "newest".into(),
|
||||
title: "Newest".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "top rated".into(),
|
||||
title: "Top Rated".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "most viewed".into(),
|
||||
title: "Most Viewed".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "top favorites".into(),
|
||||
title: "Top Favorites".into(),
|
||||
},
|
||||
],
|
||||
multiSelect: false,
|
||||
}],
|
||||
nsfw: true,
|
||||
cacheDuration: Some(1800),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_unique(target: &Arc<RwLock<Vec<FilterOption>>>, item: FilterOption) {
|
||||
if let Ok(mut vec) = target.write() {
|
||||
if !vec.iter().any(|x| x.id == item.id) {
|
||||
vec.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
sort: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let sort_string = match sort {
|
||||
"top rated" => "/sort=top_rated",
|
||||
"most viewed" => "/sort=most_viewed",
|
||||
_ => "",
|
||||
};
|
||||
let video_url = format!(
|
||||
"{}/videos{}?page={}",
|
||||
self.url, sort_string, page
|
||||
);
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
items.clone()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
let text = requester.get(&video_url, Some(Version::HTTP_2)).await.unwrap();
|
||||
if page > 1 && !text.contains(&format!("<li class=\"page-item active\"><span class=\"page-link\">{}</span>", page)) {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let video_items: Vec<VideoItem> = self
|
||||
.get_video_items_from_html(text.clone(), &mut requester)
|
||||
.await;
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
async fn query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
query: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let sort_string = match options.sort.as_deref().unwrap_or("") {
|
||||
"top rated" => "/sort=top_rated",
|
||||
"most viewed" => "/sort=most_viewed",
|
||||
_ => "",
|
||||
};
|
||||
let video_url = format!(
|
||||
"{}/search/videos{}?search_query={}&page={}",
|
||||
self.url, sort_string, query.replace(" ","+"), page
|
||||
);
|
||||
// Check our Video Cache. If the result is younger than 1 hour, we return it.
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
let _ = cache.check().await;
|
||||
return Ok(items.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
let text = requester.get(&video_url, Some(Version::HTTP_2)).await.unwrap();
|
||||
if page > 1 && !text.contains(&format!("<li class=\"page-item active\"><span class=\"page-link\">{}</span>", page)) {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let video_items: Vec<VideoItem> = self
|
||||
.get_video_items_from_html(text.clone(), &mut requester)
|
||||
.await;
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
async fn get_video_items_from_html(
|
||||
&self,
|
||||
html: String,
|
||||
requester: &mut Requester,
|
||||
) -> Vec<VideoItem> {
|
||||
if html.is_empty() || html.contains("404 Not Found") {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let block = match html
|
||||
.split("pagination ")
|
||||
.next()
|
||||
.and_then(|s| s.split("row row-cols-1 row-cols-sm-2 row-cols-lg-3 row-cols-xl-4").nth(1))
|
||||
{
|
||||
Some(b) => b,
|
||||
None => {
|
||||
eprint!("Javtiful Provider: Failed to get block from html");
|
||||
let e = Error::from(ErrorKind::Parse("html".into()));
|
||||
send_discord_error_report(
|
||||
e.to_string(),
|
||||
Some(format_error_chain(&e)),
|
||||
Some("Javtiful Provider"),
|
||||
Some(&format!("Failed to get block from html:\n```{html}\n```")),
|
||||
file!(),
|
||||
line!(),
|
||||
module_path!(),
|
||||
).await;
|
||||
return vec![]
|
||||
},
|
||||
};
|
||||
|
||||
let futures = block
|
||||
.split("card ")
|
||||
.skip(1)
|
||||
.filter(|seg| !seg.contains("SPONSOR"))
|
||||
.map(|el| self.get_video_item(el.to_string(), requester.clone()));
|
||||
|
||||
join_all(futures)
|
||||
.await
|
||||
.into_iter()
|
||||
.inspect(|r| {
|
||||
if let Err(e) = r {
|
||||
eprint!("Javtiful Provider: Failed to get video item:{}\n", e);
|
||||
// Prepare data to move into the background task
|
||||
let msg = e.to_string();
|
||||
let chain = format_error_chain(&e);
|
||||
|
||||
// Spawn the report into the background - NO .await here
|
||||
tokio::spawn(async move {
|
||||
let _ = send_discord_error_report(
|
||||
msg,
|
||||
Some(chain),
|
||||
Some("Javtiful Provider"),
|
||||
Some("Failed to get video item"),
|
||||
file!(), // Note: these might report the utility line
|
||||
line!(), // better to hardcode or pass from outside
|
||||
module_path!(),
|
||||
).await;
|
||||
});
|
||||
}
|
||||
})
|
||||
.filter_map(Result::ok)
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn get_video_item(
|
||||
&self,
|
||||
seg: String,
|
||||
mut requester: Requester,
|
||||
) -> Result<VideoItem> {
|
||||
let video_url = seg
|
||||
.split(" href=\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
.ok_or_else(|| ErrorKind::Parse("video url\n\n{seg}".into()))?
|
||||
.to_string();
|
||||
|
||||
let mut title = seg
|
||||
.split(" alt=\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
.ok_or_else(|| ErrorKind::Parse(format!("video title\n\n{seg}").into()))?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title).titlecase();
|
||||
let id = video_url
|
||||
.split('/')
|
||||
.nth(5)
|
||||
.and_then(|s| s.split('.').next())
|
||||
.ok_or_else(|| ErrorKind::Parse("video id\n\n{seg}".into()))?
|
||||
.to_string();
|
||||
let thumb_block = seg
|
||||
.split("<img ")
|
||||
.nth(1)
|
||||
.ok_or_else(|| ErrorKind::Parse("thumb block\n\n{seg}".into()))?;
|
||||
|
||||
let thumb = thumb_block
|
||||
.split("data-src=\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let mut preview = seg
|
||||
.split("data-trailer=\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let raw_duration = seg
|
||||
.split("label-duration\">")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('<').next())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let duration = parse_time_to_seconds(&raw_duration).unwrap_or(0) as u32;
|
||||
let (tags, formats, views) =
|
||||
self.extract_media(&video_url, &mut requester).await?;
|
||||
|
||||
if preview.len() == 0 {
|
||||
preview = format!("https://trailers.jav.si/preview/{id}.mp4");
|
||||
}
|
||||
let video_item = VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
video_url,
|
||||
"javtiful".into(),
|
||||
thumb,
|
||||
duration,
|
||||
)
|
||||
.formats(formats)
|
||||
.tags(tags)
|
||||
.preview(preview)
|
||||
.views(views);
|
||||
Ok(video_item)
|
||||
|
||||
}
|
||||
|
||||
async fn extract_media(
|
||||
&self,
|
||||
url: &str,
|
||||
requester: &mut Requester,
|
||||
) -> Result<(Vec<String>, Vec<VideoFormat>, u32)> {
|
||||
let text = requester
|
||||
.get(url, Some(Version::HTTP_2))
|
||||
.await
|
||||
.map_err(|e| Error::from(format!("{}", e)))?;
|
||||
let tags = text.split("related-actress").next()
|
||||
.and_then(|s| s.split("video-comments").next())
|
||||
.and_then(|s| s.split(">Tags<").nth(1))
|
||||
.map(|tag_block| {
|
||||
tag_block
|
||||
.split("<a ")
|
||||
.skip(1)
|
||||
.filter_map(|tag_el| {
|
||||
tag_el
|
||||
.split('>')
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('<').next())
|
||||
.map(|s| decode(s.as_bytes()).to_string().unwrap_or(s.to_string()).titlecase())
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_else(|| vec![]);
|
||||
for tag in &tags {
|
||||
Self::push_unique(&self.categories, FilterOption {
|
||||
id: tag.to_ascii_lowercase().replace(" ","+"),
|
||||
title: tag.to_string(),
|
||||
});
|
||||
}
|
||||
let views = text.split(" Views ")
|
||||
.next()
|
||||
.and_then(|s| s.split(" ").last())
|
||||
.and_then(|s| s.replace(".","")
|
||||
.parse::<u32>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let quality="1080p".to_string();
|
||||
let video_url = url.replace("javtiful.com","hottub.spacemoehre.de/proxy/javtiful");
|
||||
Ok((
|
||||
tags,
|
||||
vec![VideoFormat::new(video_url, quality, "video/mp4".into())],
|
||||
views,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for JavtifulProvider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
_pool: DbPool,
|
||||
sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
_per_page: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let page = page.parse::<u8>().unwrap_or(1);
|
||||
|
||||
let res = match query {
|
||||
Some(q) => self.to_owned().query(cache, page, &q, options).await,
|
||||
None => self.get(cache, page, &sort, options).await,
|
||||
};
|
||||
|
||||
res.unwrap_or_else(|e| {
|
||||
eprintln!("javtiful error: {e}");
|
||||
vec![]
|
||||
})
|
||||
}
|
||||
|
||||
fn get_channel(&self, v: ClientVersion) -> Option<Channel> {
|
||||
Some(self.build_channel(v))
|
||||
}
|
||||
}
|
||||
221
src/providers/missav.rs
Normal file
@@ -0,0 +1,221 @@
|
||||
use std::vec;
|
||||
use async_trait::async_trait;
|
||||
use diesel::r2d2;
|
||||
use error_chain::error_chain;
|
||||
use htmlentity::entity::{decode, ICodedDataTrait};
|
||||
use futures::future::join_all;
|
||||
use wreq::Version;
|
||||
use crate::db;
|
||||
use crate::providers::Provider;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::discord::{format_error_chain, send_discord_error_report};
|
||||
use crate::videos::ServerOptions;
|
||||
use crate::videos::{VideoItem};
|
||||
use crate::DbPool;
|
||||
use crate::util::requester::Requester;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
JsonError(serde_json::Error);
|
||||
Pool(r2d2::Error); // Assuming r2d2 or similar for pool
|
||||
}
|
||||
errors {
|
||||
ParsingError(t: String) {
|
||||
description("parsing error")
|
||||
display("Parsing error: '{}'", t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MissavProvider {
|
||||
url: String,
|
||||
}
|
||||
|
||||
impl MissavProvider {
|
||||
pub fn new() -> Self {
|
||||
MissavProvider {
|
||||
url: "https://missav.ws".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
async fn get(&self, cache: VideoCache, pool: DbPool, page: u8, mut sort: String, options: ServerOptions) -> Result<Vec<VideoItem>> {
|
||||
// Use ok_or to avoid unwrapping options
|
||||
let language = options.language.as_ref().ok_or("Missing language")?;
|
||||
let filter = options.filter.as_ref().ok_or("Missing filter")?;
|
||||
let mut requester = options.requester.clone().ok_or("Missing requester")?;
|
||||
|
||||
if !sort.is_empty() {
|
||||
sort = format!("&sort={}", sort);
|
||||
}
|
||||
let url_str = format!("{}/{}/{}?page={}{}", self.url, language, filter, page, sort);
|
||||
|
||||
if let Some((time, items)) = cache.get(&url_str) {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 3600 {
|
||||
return Ok(items.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let text = requester.get(&url_str, Some(Version::HTTP_2)).await.unwrap_or_else(|e| {
|
||||
eprintln!("Error fetching Missav URL {}: {}", url_str, e);
|
||||
let _ = send_discord_error_report(e.to_string(), None, Some(&url_str), None, file!(), line!(), module_path!());
|
||||
"".to_string()
|
||||
});
|
||||
let video_items = self.get_video_items_from_html(text, pool, requester).await;
|
||||
|
||||
if !video_items.is_empty() {
|
||||
cache.insert(url_str, video_items.clone());
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
async fn query(&self, cache: VideoCache, pool: DbPool, page: u8, query: &str, mut sort: String, options: ServerOptions) -> Result<Vec<VideoItem>> {
|
||||
let language = options.language.as_ref().ok_or("Missing language")?;
|
||||
let mut requester = options.requester.clone().ok_or("Missing requester")?;
|
||||
|
||||
let search_string = query.replace(" ", "%20");
|
||||
if !sort.is_empty() {
|
||||
sort = format!("&sort={}", sort);
|
||||
}
|
||||
let url_str = format!("{}/{}/search/{}?page={}{}", self.url, language, search_string, page, sort);
|
||||
|
||||
if let Some((time, items)) = cache.get(&url_str) {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 3600 {
|
||||
return Ok(items.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let text = requester.get(&url_str, Some(Version::HTTP_2)).await.unwrap_or_else(|e| {
|
||||
eprintln!("Error fetching Missav URL {}: {}", url_str, e);
|
||||
let _ = send_discord_error_report(e.to_string(), None, Some(&url_str), None, file!(), line!(), module_path!());
|
||||
"".to_string()
|
||||
});
|
||||
let video_items = self.get_video_items_from_html(text, pool, requester).await;
|
||||
|
||||
if !video_items.is_empty() {
|
||||
cache.insert(url_str, video_items.clone());
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
async fn get_video_items_from_html(&self, html: String, pool: DbPool, requester: Requester) -> Vec<VideoItem> {
|
||||
if html.is_empty() { return vec![]; }
|
||||
|
||||
let segments: Vec<&str> = html.split("@mouseenter=\"setPreview(\'").collect();
|
||||
if segments.len() < 2 { return vec![]; }
|
||||
|
||||
let mut urls = vec![];
|
||||
for video_segment in &segments[1..] {
|
||||
// Safer parsing: find start and end of href
|
||||
if let Some(start) = video_segment.find("<a href=\"") {
|
||||
let rest = &video_segment[start + 9..];
|
||||
if let Some(end) = rest.find('\"') {
|
||||
urls.push(rest[..end].to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let futures = urls.into_iter().map(|url| self.get_video_item(url, pool.clone(), requester.clone()));
|
||||
join_all(futures).await.into_iter().filter_map(Result::ok).collect()
|
||||
}
|
||||
|
||||
async fn get_video_item(&self, url_str: String, pool: DbPool, mut requester: Requester) -> Result<VideoItem> {
|
||||
// 1. Database Check
|
||||
{
|
||||
let mut conn = pool.get().map_err(|e| Error::from(format!("Pool error: {}", e)))?;
|
||||
if let Ok(Some(entry)) = db::get_video(&mut conn, url_str.clone()) {
|
||||
if let Ok(video_item) = serde_json::from_str::<VideoItem>(entry.as_str()) {
|
||||
return Ok(video_item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch Page
|
||||
let vid = requester.get(&url_str, Some(Version::HTTP_2)).await.unwrap_or_else(|e| {
|
||||
eprintln!("Error fetching Missav URL {}: {}", url_str, e);
|
||||
let _ = send_discord_error_report(e.to_string(), None, Some(&url_str), None, file!(), line!(), module_path!());
|
||||
"".to_string()
|
||||
});
|
||||
|
||||
// Helper closure to extract content between two strings
|
||||
let extract = |html: &str, start_tag: &str, end_tag: &str| -> Option<String> {
|
||||
let start = html.find(start_tag)? + start_tag.len();
|
||||
let rest = &html[start..];
|
||||
let end = rest.find(end_tag)?;
|
||||
Some(rest[..end].to_string())
|
||||
};
|
||||
|
||||
let mut title = extract(&vid, "<meta property=\"og:title\" content=\"", "\"")
|
||||
.ok_or_else(|| ErrorKind::ParsingError(format!("title\n{:?}", vid)))?;
|
||||
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
if url_str.contains("uncensored") {
|
||||
title = format!("[Uncensored] {}", title);
|
||||
}
|
||||
|
||||
let thumb = extract(&vid, "<meta property=\"og:image\" content=\"", "\"")
|
||||
.unwrap_or_default();
|
||||
|
||||
let duration = extract(&vid, "<meta property=\"og:video:duration\" content=\"", "\"")
|
||||
.and_then(|d| d.parse::<u32>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let id = url_str.split('/').last().ok_or("No ID found")?.to_string();
|
||||
|
||||
// 3. Extract Tags (Generic approach to avoid repetitive code)
|
||||
let mut tags = vec![];
|
||||
for (label, prefix) in [("Actress:", "@actress"), ("Actor:", "@actor"), ("Maker:", "@maker"), ("Genre:", "@genre")] {
|
||||
let marker = format!("<span>{}</span>", label);
|
||||
if let Some(section) = extract(&vid, &marker, "</div>") {
|
||||
for part in section.split("class=\"text-nord13 font-medium\">").skip(1) {
|
||||
if let Some(val) = part.split('<').next() {
|
||||
let clean = val.trim();
|
||||
if !clean.is_empty() {
|
||||
tags.push(format!("{}:{}", prefix, clean));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Extract Video URL (The m3u8 logic)
|
||||
let video_url = (|| {
|
||||
let parts_str = vid.split("m3u8").nth(1)?.split("https").next()?;
|
||||
let mut parts: Vec<&str> = parts_str.split('|').collect();
|
||||
parts.reverse();
|
||||
if parts.len() < 8 { return None; }
|
||||
Some(format!("https://{}.{}/{}-{}-{}-{}-{}/playlist.m3u8",
|
||||
parts[1], parts[2], parts[3], parts[4], parts[5], parts[6], parts[7]))
|
||||
})().ok_or_else(|| ErrorKind::ParsingError(format!("video_url\n{:?}", vid).to_string()))?;
|
||||
|
||||
let video_item = VideoItem::new(id, title, video_url, "missav".to_string(), thumb, duration)
|
||||
.tags(tags)
|
||||
.preview(format!("https://fourhoi.com/{}/preview.mp4", url_str.split('/').last().unwrap_or_default()));
|
||||
|
||||
// 5. Cache to DB
|
||||
if let Ok(mut conn) = pool.get() {
|
||||
let _ = db::insert_video(&mut conn, &url_str, &serde_json::to_string(&video_item).unwrap_or_default());
|
||||
}
|
||||
|
||||
Ok(video_item)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for MissavProvider {
|
||||
async fn get_videos(&self, cache: VideoCache, pool: DbPool, sort: String, query: Option<String>, page: String, _per_page: String, options: ServerOptions) -> Vec<VideoItem> {
|
||||
let page_num = page.parse::<u8>().unwrap_or(1);
|
||||
let result = match query {
|
||||
Some(q) => self.query(cache, pool, page_num, &q, sort, options).await,
|
||||
None => self.get(cache, pool, page_num, sort, options).await,
|
||||
};
|
||||
|
||||
result.unwrap_or_else(|e| {
|
||||
eprintln!("Error fetching videos: {}", e);
|
||||
let _ = send_discord_error_report(e.to_string(), Some(format_error_chain(&e)), None, None, file!(), line!(), module_path!());
|
||||
vec![]
|
||||
})
|
||||
}
|
||||
}
|
||||
104
src/providers/mod.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
use async_trait::async_trait;
|
||||
use once_cell::sync::Lazy;
|
||||
use rustc_hash::FxHashMap as HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
DbPool, api::ClientVersion, status::Channel, util::cache::VideoCache, videos::{ServerOptions, VideoItem}
|
||||
};
|
||||
|
||||
pub mod all;
|
||||
pub mod hanime;
|
||||
pub mod perverzija;
|
||||
pub mod pmvhaven;
|
||||
pub mod pornhub;
|
||||
// pub mod spankbang;
|
||||
pub mod homoxxx;
|
||||
pub mod okporn;
|
||||
pub mod okxxx;
|
||||
pub mod perfectgirls;
|
||||
pub mod pornhat;
|
||||
pub mod redtube;
|
||||
pub mod rule34video;
|
||||
// pub mod hentaimoon;
|
||||
pub mod missav;
|
||||
pub mod porn00;
|
||||
pub mod sxyprn;
|
||||
pub mod xxthots;
|
||||
// pub mod noodlemagazine;
|
||||
pub mod freshporno;
|
||||
pub mod omgxxx;
|
||||
pub mod paradisehill;
|
||||
pub mod pornzog;
|
||||
pub mod youjizz;
|
||||
pub mod beeg;
|
||||
pub mod tnaflix;
|
||||
pub mod pornxp;
|
||||
pub mod rule34gen;
|
||||
pub mod xxdbx;
|
||||
pub mod hqporner;
|
||||
pub mod noodlemagazine;
|
||||
pub mod pimpbunny;
|
||||
pub mod javtiful;
|
||||
pub mod hypnotube;
|
||||
|
||||
// convenient alias
|
||||
pub type DynProvider = Arc<dyn Provider>;
|
||||
|
||||
pub static ALL_PROVIDERS: Lazy<HashMap<&'static str, DynProvider>> = Lazy::new(|| {
|
||||
let mut m = HashMap::default();
|
||||
m.insert("omgxxx", Arc::new(omgxxx::OmgxxxProvider::new()) as DynProvider);
|
||||
m.insert("beeg", Arc::new(beeg::BeegProvider::new()) as DynProvider);
|
||||
m.insert("tnaflix", Arc::new(tnaflix::TnaflixProvider::new()) as DynProvider);
|
||||
m.insert("pornxp", Arc::new(pornxp::PornxpProvider::new()) as DynProvider);
|
||||
m.insert("rule34gen", Arc::new(rule34gen::Rule34genProvider::new()) as DynProvider);
|
||||
m.insert("xxdbx", Arc::new(xxdbx::XxdbxProvider::new()) as DynProvider);
|
||||
m.insert("hqporner", Arc::new(hqporner::HqpornerProvider::new()) as DynProvider);
|
||||
m.insert("pmvhaven", Arc::new(pmvhaven::PmvhavenProvider::new()) as DynProvider);
|
||||
m.insert("noodlemagazine", Arc::new(noodlemagazine::NoodlemagazineProvider::new()) as DynProvider);
|
||||
m.insert("pimpbunny", Arc::new(pimpbunny::PimpbunnyProvider::new()) as DynProvider);
|
||||
m.insert("javtiful", Arc::new(javtiful::JavtifulProvider::new()) as DynProvider);
|
||||
m.insert("hypnotube", Arc::new(hypnotube::HypnotubeProvider::new()) as DynProvider);
|
||||
// add more here as you migrate them
|
||||
m
|
||||
});
|
||||
|
||||
pub fn init_providers_now() {
|
||||
// Idempotent & thread-safe: runs the Lazy init exactly once.
|
||||
Lazy::force(&ALL_PROVIDERS);
|
||||
}
|
||||
|
||||
|
||||
#[async_trait]
|
||||
pub trait Provider: Send + Sync {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
pool: DbPool,
|
||||
sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
per_page: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem>;
|
||||
|
||||
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
||||
println!(
|
||||
"Getting channel for placeholder with client version: {:?}",
|
||||
clientversion
|
||||
);
|
||||
let _ = clientversion;
|
||||
Some(Channel {
|
||||
id: "placeholder".to_string(),
|
||||
name: "PLACEHOLDER".to_string(),
|
||||
description: "PLACEHOLDER FOR PARENT CLASS".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=missav.ws".to_string(),
|
||||
status: "active".to_string(),
|
||||
categories: vec![],
|
||||
options: vec![],
|
||||
nsfw: true,
|
||||
cacheDuration: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
308
src/providers/noodlemagazine.rs
Normal file
@@ -0,0 +1,308 @@
|
||||
use crate::DbPool;
|
||||
use crate::api::ClientVersion;
|
||||
use crate::providers::Provider;
|
||||
use crate::status::*;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::requester::Requester;
|
||||
use crate::util::parse_abbreviated_number;
|
||||
use crate::util::time::parse_time_to_seconds;
|
||||
use crate::videos::{ServerOptions, VideoFormat, VideoItem};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use error_chain::error_chain;
|
||||
use futures::future::join_all;
|
||||
use htmlentity::entity::{decode, ICodedDataTrait};
|
||||
use wreq::Version;
|
||||
use titlecase::Titlecase;
|
||||
use std::vec;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
}
|
||||
errors {
|
||||
Parse(msg: String)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NoodlemagazineProvider {
|
||||
url: String,
|
||||
}
|
||||
|
||||
impl NoodlemagazineProvider {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
url: "https://noodlemagazine.com".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
|
||||
Channel {
|
||||
id: "noodlemagazine".into(),
|
||||
name: "Noodlemagazine".into(),
|
||||
description: "The Best Search Engine of HD Videos".into(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=noodlemagazine.com".into(),
|
||||
status: "active".into(),
|
||||
categories: vec![],
|
||||
options: vec![],
|
||||
nsfw: true,
|
||||
cacheDuration: Some(1800),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
_sort: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let video_url = format!(
|
||||
"{}/popular/recent?sort_by=views&sort_order=desc&p={}",
|
||||
self.url,
|
||||
page.saturating_sub(1)
|
||||
);
|
||||
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((t, i)) if t.elapsed().unwrap_or_default().as_secs() < 300 => return Ok(i.clone()),
|
||||
Some((_, i)) => i.clone(),
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
let mut requester = match options.requester.clone() {
|
||||
Some(r) => r,
|
||||
None => return Ok(old_items),
|
||||
};
|
||||
|
||||
let text = requester
|
||||
.get(&video_url, Some(Version::HTTP_2))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let items = self.get_video_items_from_html(text, requester).await;
|
||||
|
||||
if items.is_empty() {
|
||||
Ok(old_items)
|
||||
} else {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url, items.clone());
|
||||
Ok(items)
|
||||
}
|
||||
}
|
||||
|
||||
async fn query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
query: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let q = query.trim().replace(' ', "%20");
|
||||
let video_url = format!("{}/video/{}?p={}", self.url, q, page.saturating_sub(1));
|
||||
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((t, i)) if t.elapsed().unwrap_or_default().as_secs() < 300 => return Ok(i.clone()),
|
||||
Some((_, i)) => i.clone(),
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
let mut requester = match options.requester.clone() {
|
||||
Some(r) => r,
|
||||
None => return Ok(old_items),
|
||||
};
|
||||
|
||||
let text = requester
|
||||
.get(&video_url, Some(Version::HTTP_2))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let items = self.get_video_items_from_html(text, requester).await;
|
||||
|
||||
if items.is_empty() {
|
||||
Ok(old_items)
|
||||
} else {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url, items.clone());
|
||||
Ok(items)
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_video_items_from_html(
|
||||
&self,
|
||||
html: String,
|
||||
requester: Requester,
|
||||
) -> Vec<VideoItem> {
|
||||
if html.is_empty() || html.contains("404 Not Found") {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let section = match html.split(">Show more</div>").next() {
|
||||
Some(s) => s,
|
||||
None => return vec![],
|
||||
};
|
||||
|
||||
let list = match section
|
||||
.split("<div class=\"list_videos\" id=\"list_videos\">")
|
||||
.nth(1)
|
||||
{
|
||||
Some(l) => l,
|
||||
None => return vec![],
|
||||
};
|
||||
|
||||
let raw_videos = list
|
||||
.split("<div class=\"item\">")
|
||||
.skip(1)
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let futures = raw_videos.map(|v| self.get_video_item(v, requester.clone()));
|
||||
let results = join_all(futures).await;
|
||||
|
||||
results.into_iter().filter_map(Result::ok).collect()
|
||||
}
|
||||
|
||||
async fn get_video_item(
|
||||
&self,
|
||||
video_segment: String,
|
||||
requester: Requester,
|
||||
) -> Result<VideoItem> {
|
||||
let href = video_segment
|
||||
.split("<a href=\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
.ok_or_else(|| Error::from("missing href"))?;
|
||||
|
||||
let video_url = format!("{}{}", self.url, href);
|
||||
|
||||
let mut title = video_segment
|
||||
.split("<div class=\"title\">")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('<').next())
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
title = decode(title.as_bytes())
|
||||
.to_string()
|
||||
.unwrap_or(title)
|
||||
.titlecase();
|
||||
|
||||
let id = video_url
|
||||
.split('/')
|
||||
.nth(4)
|
||||
.and_then(|s| s.split('.').next())
|
||||
.ok_or_else(|| Error::from("missing id"))?
|
||||
.to_string();
|
||||
|
||||
let thumb = video_segment
|
||||
.split("data-src=\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let raw_duration = video_segment
|
||||
.split("#clock-o\"></use></svg>")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('<').next())
|
||||
.unwrap_or("0:00");
|
||||
|
||||
let duration = parse_time_to_seconds(raw_duration).unwrap_or(0) as u32;
|
||||
|
||||
let views = video_segment
|
||||
.split("#eye\"></use></svg>")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('<').next())
|
||||
.and_then(|v| parse_abbreviated_number(v.trim()))
|
||||
.unwrap_or(0);
|
||||
|
||||
let formats = self
|
||||
.extract_media(&video_url, requester)
|
||||
.await
|
||||
.ok_or_else(|| Error::from("media extraction failed"))?;
|
||||
|
||||
Ok(
|
||||
VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
video_url,
|
||||
"noodlemagazine".into(),
|
||||
thumb,
|
||||
duration,
|
||||
)
|
||||
.views(views)
|
||||
.formats(formats),
|
||||
)
|
||||
}
|
||||
|
||||
async fn extract_media(
|
||||
&self,
|
||||
video_url: &String,
|
||||
mut requester: Requester,
|
||||
) -> Option<Vec<VideoFormat>> {
|
||||
let text = requester
|
||||
.get(video_url, Some(Version::HTTP_2))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let json_str = text
|
||||
.split("window.playlist = ")
|
||||
.nth(1)?
|
||||
.split(';')
|
||||
.next()?;
|
||||
|
||||
let json: serde_json::Value = serde_json::from_str(json_str).ok()?;
|
||||
let sources = json["sources"].as_array()?;
|
||||
|
||||
let mut formats = vec![];
|
||||
|
||||
for s in sources {
|
||||
let file = s["file"].as_str()?.to_string();
|
||||
let label = s["label"].as_str().unwrap_or("unknown").to_string();
|
||||
|
||||
formats.push(
|
||||
VideoFormat::new(file, label.clone(), "video/mp4".into())
|
||||
.format_id(label.clone())
|
||||
.format_note(label.clone())
|
||||
.http_header("Referer".into(), video_url.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
Some(formats.into_iter().rev().collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for NoodlemagazineProvider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
pool: DbPool,
|
||||
sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
per_page: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let _ = pool;
|
||||
let _ = per_page;
|
||||
|
||||
let page = page.parse::<u8>().unwrap_or(1);
|
||||
|
||||
let res = match query {
|
||||
Some(q) => self.query(cache, page, &q, options).await,
|
||||
None => self.get(cache, page, &sort, options).await,
|
||||
};
|
||||
|
||||
res.unwrap_or_else(|e| {
|
||||
eprintln!("Noodlemagazine error: {e}");
|
||||
vec![]
|
||||
})
|
||||
}
|
||||
|
||||
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
||||
Some(self.build_channel(clientversion))
|
||||
}
|
||||
}
|
||||
269
src/providers/okporn.rs
Normal file
@@ -0,0 +1,269 @@
|
||||
use crate::DbPool;
|
||||
use crate::providers::Provider;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::flaresolverr::{FlareSolverrRequest, Flaresolverr};
|
||||
use crate::util::time::parse_time_to_seconds;
|
||||
use crate::videos::{ServerOptions, VideoItem};
|
||||
use error_chain::error_chain;
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
use std::env;
|
||||
use std::vec;
|
||||
use wreq::{Client};
|
||||
use wreq_util::Emulation;
|
||||
use async_trait::async_trait;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OkpornProvider {
|
||||
url: String,
|
||||
}
|
||||
impl OkpornProvider {
|
||||
pub fn new() -> Self {
|
||||
OkpornProvider {
|
||||
url: "https://ok.porn".to_string(),
|
||||
}
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
sort: &str,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let sort_string = match sort {
|
||||
"trending" => "/trending",
|
||||
"popular" => "/popular",
|
||||
_ => "",
|
||||
};
|
||||
let video_url = format!("{}{}/{}/", self.url, sort_string, page);
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
items.clone()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
// let proxy = Proxy::all("http://192.168.0.103:8081").unwrap();
|
||||
let client = Client::builder().cert_verification(false).emulation(Emulation::Firefox136).build()?;
|
||||
|
||||
let mut response = client.get(video_url.clone())
|
||||
// .proxy(proxy.clone())
|
||||
.send().await?;
|
||||
if response.status().is_redirection(){
|
||||
|
||||
response = client.get(self.url.clone() + response.headers()["Location"].to_str().unwrap())
|
||||
// .proxy(proxy.clone())
|
||||
.send().await?;
|
||||
}
|
||||
if response.status().is_success() {
|
||||
let text = response.text().await?;
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
} else {
|
||||
let flare_url = env::var("FLARE_URL").expect("FLARE_URL not set");
|
||||
let flare = Flaresolverr::new(flare_url);
|
||||
let result = flare
|
||||
.solve(FlareSolverrRequest {
|
||||
cmd: "request.get".to_string(),
|
||||
url: video_url.clone(),
|
||||
maxTimeout: 60000,
|
||||
})
|
||||
.await;
|
||||
let video_items = match result {
|
||||
Ok(res) => {
|
||||
// println!("FlareSolverr response: {}", res);
|
||||
self.get_video_items_from_html(res.solution.response)
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Error solving FlareSolverr: {}", e);
|
||||
return Err("Failed to solve FlareSolverr".into());
|
||||
}
|
||||
};
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
}
|
||||
async fn query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
query: &str,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let search_string = query.to_lowercase().trim().replace(" ", "-");
|
||||
let video_url = format!("{}/search/{}/{}/", self.url, search_string, page);
|
||||
|
||||
// Check our Video Cache. If the result is younger than 1 hour, we return it.
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
let _ = cache.check().await;
|
||||
return Ok(items.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
// let proxy = Proxy::all("http://192.168.0.103:8081").unwrap();
|
||||
let client = Client::builder().cert_verification(false).emulation(Emulation::Firefox136).build()?;
|
||||
|
||||
let mut response = client.get(video_url.clone())
|
||||
// .proxy(proxy.clone())
|
||||
.send().await?;
|
||||
|
||||
if response.status().is_redirection(){
|
||||
|
||||
response = client.get(self.url.clone() + response.headers()["Location"].to_str().unwrap())
|
||||
// .proxy(proxy.clone())
|
||||
.send().await?;
|
||||
}
|
||||
|
||||
if response.status().is_success() {
|
||||
let text = response.text().await?;
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
} else {
|
||||
let flare_url = env::var("FLARE_URL").expect("FLARE_URL not set");
|
||||
let flare = Flaresolverr::new(flare_url);
|
||||
let result = flare
|
||||
.solve(FlareSolverrRequest {
|
||||
cmd: "request.get".to_string(),
|
||||
url: video_url.clone(),
|
||||
maxTimeout: 60000,
|
||||
})
|
||||
.await;
|
||||
let video_items = match result {
|
||||
Ok(res) => self.get_video_items_from_html(res.solution.response),
|
||||
Err(e) => {
|
||||
println!("Error solving FlareSolverr: {}", e);
|
||||
return Err("Failed to solve FlareSolverr".into());
|
||||
}
|
||||
};
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_video_items_from_html(&self, html: String) -> Vec<VideoItem> {
|
||||
if html.is_empty() {
|
||||
println!("HTML is empty");
|
||||
return vec![];
|
||||
}
|
||||
let mut items: Vec<VideoItem> = Vec::new();
|
||||
let raw_videos = html
|
||||
.split("<div class=\"item ")
|
||||
.collect::<Vec<&str>>()[1..]
|
||||
.to_vec();
|
||||
for video_segment in &raw_videos {
|
||||
// let vid = video_segment.split("\n").collect::<Vec<&str>>();
|
||||
// for (index, line) in vid.iter().enumerate() {
|
||||
// println!("Line {}: {}", index, line);
|
||||
// }
|
||||
let video_url: String = format!("{}{}", self.url, video_segment.split("<a href=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]);
|
||||
let mut title = video_segment.split("\" title=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
// html decode
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
let id = video_url.split("/").collect::<Vec<&str>>()[4].to_string();
|
||||
let raw_duration = video_segment.split("<span class=\"duration_item\">").collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let duration = parse_time_to_seconds(&raw_duration).unwrap_or(0) as u32;
|
||||
|
||||
let thumb = video_segment.split("<img class=\"thumb lazy-load\" src=\"").collect::<Vec<&str>>()[1].split("data-original=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
|
||||
let video_item = VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
video_url.to_string(),
|
||||
"okporn".to_string(),
|
||||
thumb,
|
||||
duration,
|
||||
)
|
||||
;
|
||||
items.push(video_item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for OkpornProvider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
pool: DbPool,
|
||||
sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
per_page: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let _ = options;
|
||||
let _ = per_page;
|
||||
let _ = pool;
|
||||
let videos: std::result::Result<Vec<VideoItem>, Error> = match query {
|
||||
Some(q) => {
|
||||
self.query(cache, page.parse::<u8>().unwrap_or(1), &q,)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
self.get(cache, page.parse::<u8>().unwrap_or(1), &sort)
|
||||
.await
|
||||
}
|
||||
};
|
||||
match videos {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
println!("Error fetching videos: {}", e);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
314
src/providers/okxxx.rs
Normal file
@@ -0,0 +1,314 @@
|
||||
use crate::util::parse_abbreviated_number;
|
||||
use crate::DbPool;
|
||||
use crate::providers::Provider;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::flaresolverr::{FlareSolverrRequest, Flaresolverr};
|
||||
use crate::util::time::parse_time_to_seconds;
|
||||
use crate::videos::{ServerOptions, VideoItem};
|
||||
use error_chain::error_chain;
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
use std::env;
|
||||
use std::vec;
|
||||
use wreq::{Client};
|
||||
use wreq_util::Emulation;
|
||||
use async_trait::async_trait;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OkxxxProvider {
|
||||
url: String,
|
||||
}
|
||||
impl OkxxxProvider {
|
||||
pub fn new() -> Self {
|
||||
OkxxxProvider {
|
||||
url: "https://ok.xxx".to_string(),
|
||||
}
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
sort: &str,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let sort_string = match sort {
|
||||
"trending" => "/trending",
|
||||
"popular" => "/popular",
|
||||
_ => "",
|
||||
};
|
||||
let video_url = format!("{}{}/{}/", self.url, sort_string, page);
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
items.clone()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
// let proxy = Proxy::all("http://192.168.0.103:8081").unwrap();
|
||||
let client = Client::builder().cert_verification(false).emulation(Emulation::Firefox136).build()?;
|
||||
|
||||
let mut response = client.get(video_url.clone())
|
||||
// .proxy(proxy.clone())
|
||||
.send().await?;
|
||||
if response.status().is_redirection(){
|
||||
println!("Redirection detected, following to: {}", response.headers()["Location"].to_str().unwrap());
|
||||
response = client.get(response.headers()["Location"].to_str().unwrap())
|
||||
// .proxy(proxy.clone())
|
||||
.send().await?;
|
||||
}
|
||||
if response.status().is_success() {
|
||||
let text = response.text().await?;
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
} else {
|
||||
let flare_url = env::var("FLARE_URL").expect("FLARE_URL not set");
|
||||
let flare = Flaresolverr::new(flare_url);
|
||||
let result = flare
|
||||
.solve(FlareSolverrRequest {
|
||||
cmd: "request.get".to_string(),
|
||||
url: video_url.clone(),
|
||||
maxTimeout: 60000,
|
||||
})
|
||||
.await;
|
||||
let video_items = match result {
|
||||
Ok(res) => {
|
||||
// println!("FlareSolverr response: {}", res);
|
||||
self.get_video_items_from_html(res.solution.response)
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Error solving FlareSolverr: {}", e);
|
||||
return Err("Failed to solve FlareSolverr".into());
|
||||
}
|
||||
};
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
}
|
||||
async fn query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
query: &str,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let search_string = query.to_lowercase().trim().replace(" ", "-");
|
||||
let mut video_url = format!("{}/search/{}/{}/", self.url, search_string, page);
|
||||
|
||||
if search_string.starts_with("@"){
|
||||
let url_part = search_string.split("@").collect::<Vec<&str>>()[1].replace(":", "/");
|
||||
video_url = format!("{}/{}/", self.url, url_part);
|
||||
}
|
||||
// Check our Video Cache. If the result is younger than 1 hour, we return it.
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
let _ = cache.check().await;
|
||||
return Ok(items.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
// let proxy = Proxy::all("http://192.168.0.103:8081").unwrap();
|
||||
let client = Client::builder().cert_verification(false).emulation(Emulation::Firefox136).build()?;
|
||||
|
||||
let mut response = client.get(video_url.clone())
|
||||
// .proxy(proxy.clone())
|
||||
.send().await?;
|
||||
|
||||
if response.status().is_redirection(){
|
||||
|
||||
response = client.get(self.url.clone() + response.headers()["Location"].to_str().unwrap())
|
||||
// .proxy(proxy.clone())
|
||||
.send().await?;
|
||||
}
|
||||
|
||||
if response.status().is_success() {
|
||||
let text = response.text().await?;
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
} else {
|
||||
let flare_url = env::var("FLARE_URL").expect("FLARE_URL not set");
|
||||
let flare = Flaresolverr::new(flare_url);
|
||||
let result = flare
|
||||
.solve(FlareSolverrRequest {
|
||||
cmd: "request.get".to_string(),
|
||||
url: video_url.clone(),
|
||||
maxTimeout: 60000,
|
||||
})
|
||||
.await;
|
||||
let video_items = match result {
|
||||
Ok(res) => self.get_video_items_from_html(res.solution.response),
|
||||
Err(e) => {
|
||||
println!("Error solving FlareSolverr: {}", e);
|
||||
return Err("Failed to solve FlareSolverr".into());
|
||||
}
|
||||
};
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_video_items_from_html(&self, html: String) -> Vec<VideoItem> {
|
||||
if html.is_empty() {
|
||||
println!("HTML is empty");
|
||||
return vec![];
|
||||
}
|
||||
let mut items: Vec<VideoItem> = Vec::new();
|
||||
let raw_videos = html.split("<div class=\"pagination\"").collect::<Vec<&str>>()[0]
|
||||
.split("item thumb-bl thumb-bl-video video_")
|
||||
.collect::<Vec<&str>>()[1..]
|
||||
.to_vec();
|
||||
for video_segment in &raw_videos {
|
||||
// let vid = video_segment.split("\n").collect::<Vec<&str>>();
|
||||
// for (index, line) in vid.iter().enumerate() {
|
||||
// println!("Line {}: {}", index, line);
|
||||
// }
|
||||
let video_url: String = format!("{}{}", self.url, video_segment.split("<a href=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]);
|
||||
let preview_url = video_segment.split("data-preview-custom=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let mut title = video_segment.split("\" title=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
// html decode
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
let id = video_url.split("/").collect::<Vec<&str>>()[4].to_string();
|
||||
let raw_duration = video_segment.split("fa fa-clock-o").collect::<Vec<&str>>()[1]
|
||||
.split("<span>").collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let duration = parse_time_to_seconds(&raw_duration).unwrap_or(0) as u32;
|
||||
|
||||
let thumb = format!("https:{}", video_segment.split(" class=\"thumb lazy-load\"").collect::<Vec<&str>>()[1]
|
||||
.split("data-original=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string());
|
||||
|
||||
let mut tags = vec![];
|
||||
if video_segment.contains("href=\"/sites/"){
|
||||
let raw_tags = video_segment.split("href=\"/sites/").collect::<Vec<&str>>()[1..]
|
||||
.iter()
|
||||
.map(|s| s.split("/\"").collect::<Vec<&str>>()[0].to_string())
|
||||
.collect::<Vec<String>>();
|
||||
for tag in raw_tags {
|
||||
if !tag.is_empty() {
|
||||
tags.push(format!("@sites:{}",tag));
|
||||
}
|
||||
}
|
||||
}
|
||||
if video_segment.contains("href=\"/models/"){
|
||||
let raw_tags = video_segment.split("href=\"/models/").collect::<Vec<&str>>()[1..]
|
||||
.iter()
|
||||
.map(|s| s.split("/\"").collect::<Vec<&str>>()[0].to_string())
|
||||
.collect::<Vec<String>>();
|
||||
for tag in raw_tags {
|
||||
if !tag.is_empty() {
|
||||
tags.push(format!("@models:{}",tag));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let views_part = video_segment.split("fa fa-eye").collect::<Vec<&str>>()[1]
|
||||
.split("<span>").collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let views = parse_abbreviated_number(&views_part).unwrap_or(0) as u32;
|
||||
|
||||
let video_item = VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
video_url.to_string(),
|
||||
"okxxx".to_string(),
|
||||
thumb,
|
||||
duration,
|
||||
)
|
||||
.preview(preview_url)
|
||||
.views(views)
|
||||
.tags(tags)
|
||||
;
|
||||
items.push(video_item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for OkxxxProvider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
pool: DbPool,
|
||||
sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
per_page: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let _ = options;
|
||||
let _ = per_page;
|
||||
let _ = pool;
|
||||
let videos: std::result::Result<Vec<VideoItem>, Error> = match query {
|
||||
Some(q) => {
|
||||
self.query(cache, page.parse::<u8>().unwrap_or(1), &q,)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
self.get(cache, page.parse::<u8>().unwrap_or(1), &sort)
|
||||
.await
|
||||
}
|
||||
};
|
||||
match videos {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
println!("Error fetching videos: {}", e);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
627
src/providers/omgxxx.rs
Normal file
@@ -0,0 +1,627 @@
|
||||
use crate::DbPool;
|
||||
use crate::api::ClientVersion;
|
||||
use crate::providers::Provider;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::parse_abbreviated_number;
|
||||
use crate::util::time::parse_time_to_seconds;
|
||||
use crate::videos::{ServerOptions, VideoItem};
|
||||
use crate::{status::*, util};
|
||||
use async_trait::async_trait;
|
||||
use error_chain::error_chain;
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::thread;
|
||||
use std::vec;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OmgxxxProvider {
|
||||
url: String,
|
||||
sites: Arc<RwLock<Vec<FilterOption>>>,
|
||||
networks: Arc<RwLock<Vec<FilterOption>>>,
|
||||
stars: Arc<RwLock<Vec<FilterOption>>>,
|
||||
}
|
||||
impl OmgxxxProvider {
|
||||
pub fn new() -> Self {
|
||||
let provider = OmgxxxProvider {
|
||||
url: "https://www.omg.xxx".to_string(),
|
||||
sites: Arc::new(RwLock::new(vec![FilterOption {
|
||||
id: "all".to_string(),
|
||||
title: "All".to_string(),
|
||||
}])),
|
||||
networks: Arc::new(RwLock::new(vec![FilterOption {
|
||||
id: "all".to_string(),
|
||||
title: "All".to_string(),
|
||||
}])),
|
||||
stars: Arc::new(RwLock::new(vec![FilterOption {
|
||||
id: "all".to_string(),
|
||||
title: "All".to_string(),
|
||||
}])),
|
||||
};
|
||||
|
||||
// Kick off the background load but return immediately
|
||||
provider.spawn_initial_load();
|
||||
provider
|
||||
}
|
||||
|
||||
fn spawn_initial_load(&self) {
|
||||
let url = self.url.clone();
|
||||
let sites = Arc::clone(&self.sites);
|
||||
let networks = Arc::clone(&self.networks);
|
||||
let stars = Arc::clone(&self.stars);
|
||||
|
||||
thread::spawn(move || {
|
||||
// Create a tiny runtime just for these async tasks
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("build tokio runtime");
|
||||
|
||||
rt.block_on(async move {
|
||||
// If you have a streaming sites loader, call it here too
|
||||
if let Err(e) = Self::load_sites(&url, sites).await {
|
||||
eprintln!("load_sites_into failed: {e}");
|
||||
}
|
||||
|
||||
if let Err(e) = Self::load_networks(&url, networks).await {
|
||||
eprintln!("load_networks failed: {e}");
|
||||
}
|
||||
|
||||
if let Err(e) = Self::load_stars(&url, stars).await {
|
||||
eprintln!("load_stars failed: {e}");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async fn load_stars(base_url: &str, stars: Arc<RwLock<Vec<FilterOption>>>) -> Result<()> {
|
||||
let mut requester = util::requester::Requester::new();
|
||||
for page in [1..10].into_iter().flatten() {
|
||||
let text = requester
|
||||
.get(
|
||||
format!("{}/models/total-videos/{}/?gender_id=0", &base_url, page).as_str(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
if text.contains("404 Not Found") || text.is_empty() {
|
||||
break;
|
||||
}
|
||||
let stars_div = text
|
||||
.split("<div class=\"list-models\">")
|
||||
.collect::<Vec<&str>>()
|
||||
.last()
|
||||
.unwrap()
|
||||
.split("custom_list_models_models_list_pagination")
|
||||
.collect::<Vec<&str>>()[0];
|
||||
for stars_element in stars_div.split("<a ").collect::<Vec<&str>>()[1..].to_vec() {
|
||||
let star_url = stars_element.split("href=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0];
|
||||
let star_id = star_url.split("/").collect::<Vec<&str>>()[4].to_string();
|
||||
let star_name = stars_element
|
||||
.split("<strong class=\"title\">")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
Self::push_unique(
|
||||
&stars,
|
||||
FilterOption {
|
||||
id: star_id,
|
||||
title: star_name,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
async fn load_sites(base_url: &str, sites: Arc<RwLock<Vec<FilterOption>>>) -> Result<()> {
|
||||
let mut requester = util::requester::Requester::new();
|
||||
let mut page = 0;
|
||||
loop {
|
||||
page += 1;
|
||||
let text = requester
|
||||
.get(format!("{}/sites/{}/", &base_url, page).as_str(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
if text.contains("404 Not Found") || text.is_empty() {
|
||||
break;
|
||||
}
|
||||
let sites_div = text
|
||||
.split("id=\"list_content_sources_sponsors_list_items\"")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("class=\"pagination\"")
|
||||
.collect::<Vec<&str>>()[0];
|
||||
for sites_element in
|
||||
sites_div.split("class=\"headline\"").collect::<Vec<&str>>()[1..].to_vec()
|
||||
{
|
||||
let site_url = sites_element.split("href=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0];
|
||||
let site_id = site_url.split("/").collect::<Vec<&str>>()[4].to_string();
|
||||
let site_name = sites_element.split("<h2>").collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
Self::push_unique(
|
||||
&sites,
|
||||
FilterOption {
|
||||
id: site_id,
|
||||
title: site_name,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
async fn load_networks(base_url: &str, networks: Arc<RwLock<Vec<FilterOption>>>) -> Result<()> {
|
||||
let mut requester = util::requester::Requester::new();
|
||||
let text = requester.get(&base_url, None).await.unwrap();
|
||||
let networks_div = text.split("class=\"sites__list\"").collect::<Vec<&str>>()[1]
|
||||
.split("</div>")
|
||||
.collect::<Vec<&str>>()[0];
|
||||
for network_element in
|
||||
networks_div.split("sites__item").collect::<Vec<&str>>()[1..].to_vec()
|
||||
{
|
||||
if network_element.contains("sites__all") {
|
||||
continue;
|
||||
}
|
||||
let network_url = network_element.split("href=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0];
|
||||
let network_id = network_url.split("/").collect::<Vec<&str>>()[4].to_string();
|
||||
let network_name = network_element.split(">").collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
Self::push_unique(
|
||||
&networks,
|
||||
FilterOption {
|
||||
id: network_id,
|
||||
title: network_name,
|
||||
},
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Push one item with minimal lock time and dedup by id
|
||||
fn push_unique(target: &Arc<RwLock<Vec<FilterOption>>>, item: FilterOption) {
|
||||
if let Ok(mut vec) = target.write() {
|
||||
if !vec.iter().any(|x| x.id == item.id) {
|
||||
vec.push(item);
|
||||
// Optional: keep it sorted for nicer UX
|
||||
// vec.sort_by(|a,b| a.title.cmp(&b.title));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_channel(&self, clientversion: ClientVersion) -> Channel {
|
||||
let _ = clientversion;
|
||||
let sites: Vec<FilterOption> = self
|
||||
.sites
|
||||
.read()
|
||||
.map(|g| g.clone()) // or: .map(|g| g.to_vec())
|
||||
.unwrap_or_default(); // or: .unwrap_or_else(|_| Vec::new())
|
||||
|
||||
let networks: Vec<FilterOption> = self
|
||||
.networks
|
||||
.read()
|
||||
.map(|g| g.clone()) // or: .map(|g| g.to_vec())
|
||||
.unwrap_or_default(); // or: .unwrap_or_else(|_| Vec::new())
|
||||
|
||||
let stars: Vec<FilterOption> = self
|
||||
.stars
|
||||
.read()
|
||||
.map(|g| g.clone()) // or: .map(|g| g.to_vec())
|
||||
.unwrap_or_default(); // or: .unwrap_or_else(|_| Vec::new())
|
||||
|
||||
Channel {
|
||||
id: "omgxxx".to_string(),
|
||||
name: "OMG XXX".to_string(),
|
||||
description: "OMG look at that Collection!".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=www.omg.xxx".to_string(),
|
||||
status: "active".to_string(),
|
||||
categories: vec![],
|
||||
options: vec![
|
||||
ChannelOption {
|
||||
id: "sort".to_string(),
|
||||
title: "Sort".to_string(),
|
||||
description: "Sort the Videos".to_string(),
|
||||
systemImage: "list.number".to_string(),
|
||||
colorName: "blue".to_string(),
|
||||
options: vec![
|
||||
FilterOption {
|
||||
id: "latest-updates".into(),
|
||||
title: "Latest".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "most-popular".into(),
|
||||
title: "Most Viewed".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "top-rated".into(),
|
||||
title: "Top Rated".into(),
|
||||
},
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
ChannelOption {
|
||||
id: "sites".to_string(),
|
||||
title: "Sites".to_string(),
|
||||
description: "Filter for different Sites".to_string(),
|
||||
systemImage: "rectangle.stack".to_string(),
|
||||
colorName: "green".to_string(),
|
||||
options: sites,
|
||||
multiSelect: false,
|
||||
},
|
||||
ChannelOption {
|
||||
id: "networks".to_string(),
|
||||
title: "Networks".to_string(),
|
||||
description: "Filter for different Networks".to_string(),
|
||||
systemImage: "list.dash".to_string(),
|
||||
colorName: "purple".to_string(),
|
||||
options: networks,
|
||||
multiSelect: false,
|
||||
},
|
||||
ChannelOption {
|
||||
id: "stars".to_string(),
|
||||
title: "Stars".to_string(),
|
||||
description: "Filter for different Pornstars".to_string(),
|
||||
systemImage: "star.fill".to_string(),
|
||||
colorName: "yellow".to_string(),
|
||||
options: stars,
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
nsfw: true,
|
||||
cacheDuration: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
sort: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let mut sort_string: String = match sort {
|
||||
"top-rated" => "top-rated".to_string(),
|
||||
"most-popular" => "most-popular".to_string(),
|
||||
_ => "latest-updates".to_string(),
|
||||
};
|
||||
let alt_sort_string: String = match sort {
|
||||
"top-rated" => "/top-rated".to_string(),
|
||||
"most-popular" => "/most-popular".to_string(),
|
||||
_ => "".to_string(),
|
||||
};
|
||||
if options.network.is_some()
|
||||
&& !options.network.as_ref().unwrap().is_empty()
|
||||
&& options.network.as_ref().unwrap() != "all"
|
||||
{
|
||||
sort_string = format!(
|
||||
"networks/{}{}",
|
||||
options.network.as_ref().unwrap(),
|
||||
alt_sort_string
|
||||
);
|
||||
}
|
||||
if options.sites.is_some()
|
||||
&& !options.sites.as_ref().unwrap().is_empty()
|
||||
&& options.sites.as_ref().unwrap() != "all"
|
||||
{
|
||||
sort_string = format!(
|
||||
"sites/{}{}",
|
||||
options.sites.as_ref().unwrap(),
|
||||
alt_sort_string
|
||||
);
|
||||
}
|
||||
if options.stars.is_some()
|
||||
&& !options.stars.as_ref().unwrap().is_empty()
|
||||
&& options.stars.as_ref().unwrap() != "all"
|
||||
{
|
||||
sort_string = format!(
|
||||
"models/{}{}",
|
||||
options.stars.as_ref().unwrap(),
|
||||
alt_sort_string
|
||||
);
|
||||
}
|
||||
let video_url = format!("{}/{}/{}/", self.url, sort_string, page);
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
items.clone()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
let text = requester.get(&video_url, None).await.unwrap();
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
async fn query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
query: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let mut search_type = "search";
|
||||
let mut search_string = query.to_string().to_ascii_lowercase().trim().to_string();
|
||||
match self
|
||||
.stars
|
||||
.read()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|s| s.title.to_ascii_lowercase() == search_string)
|
||||
{
|
||||
Some(star) => {
|
||||
search_type = "models";
|
||||
search_string = star.id.clone();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
match self
|
||||
.sites
|
||||
.read()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|s| s.title.to_ascii_lowercase() == search_string)
|
||||
{
|
||||
Some(site) => {
|
||||
search_type = "sites";
|
||||
search_string = site.id.clone();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let mut video_url = format!("{}/{}/{}/{}/", self.url, search_type, search_string, page);
|
||||
video_url = video_url.replace(" ", "+");
|
||||
// Check our Video Cache. If the result is younger than 1 hour, we return it.
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
let _ = cache.check().await;
|
||||
return Ok(items.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
|
||||
let text = requester.get(&video_url, None).await.unwrap();
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
fn get_site_id_from_name(&self, site_name: &str) -> Option<String> {
|
||||
// site_name.to_lowercase().replace(" ", "")
|
||||
for site in self.sites.read().unwrap().iter() {
|
||||
if site
|
||||
.title
|
||||
.to_lowercase()
|
||||
.replace(" ", "")
|
||||
.replace(".com", "")
|
||||
== site_name.to_lowercase().replace(" ", "")
|
||||
{
|
||||
return Some(site.id.clone());
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
fn get_video_items_from_html(&self, html: String) -> Vec<VideoItem> {
|
||||
if html.is_empty() {
|
||||
println!("HTML is empty");
|
||||
return vec![];
|
||||
}
|
||||
let mut items: Vec<VideoItem> = Vec::new();
|
||||
if !html.contains("class=\"item\"") {
|
||||
return items;
|
||||
}
|
||||
let raw_videos = html.split("videos_list_pagination").collect::<Vec<&str>>()[0]
|
||||
.split(" class=\"pagination\" ")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.split("class=\"list-videos\"")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("class=\"item\"")
|
||||
.collect::<Vec<&str>>()[1..]
|
||||
.to_vec();
|
||||
for video_segment in &raw_videos {
|
||||
// let vid = video_segment.split("\n").collect::<Vec<&str>>();
|
||||
// for (index, line) in vid.iter().enumerate() {
|
||||
// println!("Line {}: {}", index, line);
|
||||
// }
|
||||
let video_url: String = video_segment.split("<a href=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let mut title = video_segment.split(" title=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
// html decode
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
let id = video_url.split("/").collect::<Vec<&str>>()[4].to_string();
|
||||
|
||||
let thumb = match video_segment.split("img loading").collect::<Vec<&str>>()[1]
|
||||
.contains("data-src=\"")
|
||||
{
|
||||
true => video_segment.split("img loading").collect::<Vec<&str>>()[1]
|
||||
.split("data-src=\"")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string(),
|
||||
false => video_segment.split("img loading").collect::<Vec<&str>>()[1]
|
||||
.split("data-original=\"")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string(),
|
||||
};
|
||||
let raw_duration = video_segment
|
||||
.split("<span class=\"duration\">")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.split(" ")
|
||||
.collect::<Vec<&str>>()
|
||||
.last()
|
||||
.unwrap_or(&"")
|
||||
.to_string();
|
||||
let duration = parse_time_to_seconds(raw_duration.as_str()).unwrap_or(0) as u32;
|
||||
let views = parse_abbreviated_number(
|
||||
video_segment
|
||||
.split("<div class=\"views\">")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string()
|
||||
.as_str(),
|
||||
)
|
||||
.unwrap_or(0) as u32;
|
||||
|
||||
let preview = video_segment
|
||||
.split("data-preview=\"")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let site_name = title
|
||||
.split("]")
|
||||
.collect::<Vec<&str>>()
|
||||
.first()
|
||||
.unwrap_or(&"")
|
||||
.trim_start_matches("[");
|
||||
let site_id = self
|
||||
.get_site_id_from_name(site_name)
|
||||
.unwrap_or("".to_string());
|
||||
let mut tags = match video_segment.contains("class=\"models\">") {
|
||||
true => video_segment
|
||||
.split("class=\"models\">")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("</div>")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.split("href=\"")
|
||||
.collect::<Vec<&str>>()[1..]
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
Self::push_unique(
|
||||
&self.stars,
|
||||
FilterOption {
|
||||
id: s.split("/").collect::<Vec<&str>>()[4].to_string(),
|
||||
title: s.split(">").collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.trim()
|
||||
.to_string(),
|
||||
},
|
||||
);
|
||||
s.split(">").collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.trim()
|
||||
.to_string()
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
.to_vec(),
|
||||
false => vec![],
|
||||
};
|
||||
if !site_id.is_empty() {
|
||||
Self::push_unique(
|
||||
&self.sites,
|
||||
FilterOption {
|
||||
id: site_id,
|
||||
title: site_name.to_string(),
|
||||
},
|
||||
);
|
||||
tags.push(site_name.to_string());
|
||||
}
|
||||
|
||||
let video_item = VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
video_url.to_string(),
|
||||
"omgxxx".to_string(),
|
||||
thumb,
|
||||
duration,
|
||||
)
|
||||
.views(views)
|
||||
.preview(preview)
|
||||
.tags(tags);
|
||||
items.push(video_item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for OmgxxxProvider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
pool: DbPool,
|
||||
sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
per_page: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let _ = per_page;
|
||||
let _ = pool;
|
||||
let videos: std::result::Result<Vec<VideoItem>, Error> = match query {
|
||||
Some(q) => {
|
||||
self.query(cache, page.parse::<u8>().unwrap_or(1), &q, options)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
self.get(cache, page.parse::<u8>().unwrap_or(1), &sort, options)
|
||||
.await
|
||||
}
|
||||
};
|
||||
match videos {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
println!("Error fetching videos: {}", e);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
fn get_channel(&self, clientversion: ClientVersion) -> Option<crate::status::Channel> {
|
||||
Some(self.build_channel(clientversion))
|
||||
}
|
||||
}
|
||||
240
src/providers/paradisehill.rs
Normal file
@@ -0,0 +1,240 @@
|
||||
use crate::DbPool;
|
||||
use crate::providers::Provider;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::requester::Requester;
|
||||
use crate::videos::VideoItem;
|
||||
use crate::videos::{self, ServerOptions};
|
||||
use error_chain::error_chain;
|
||||
use futures::future::join_all;
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
use std::vec;
|
||||
use async_trait::async_trait;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
JsonError(serde_json::Error);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParadisehillProvider {
|
||||
url: String,
|
||||
}
|
||||
impl ParadisehillProvider {
|
||||
pub fn new() -> Self {
|
||||
ParadisehillProvider {
|
||||
url: "https://en.paradisehill.cc".to_string(),
|
||||
}
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
|
||||
let url_str = format!("{}/all/?sort=created_at&page={}", self.url, page);
|
||||
|
||||
let old_items = match cache.get(&url_str) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 60 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
items.clone()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let text = requester.get(&url_str, None).await.unwrap();
|
||||
// Pass a reference to options if needed, or reconstruct as needed
|
||||
let video_items: Vec<VideoItem> = self
|
||||
.get_video_items_from_html(text.clone(), requester)
|
||||
.await;
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&url_str);
|
||||
cache.insert(url_str.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
async fn query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
query: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
// Extract needed fields from options at the start
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
let search_string = query.replace(" ", "+");
|
||||
let url_str = format!(
|
||||
"{}/search/?pattern={}&page={}",
|
||||
self.url, search_string, page
|
||||
);
|
||||
// Check our Video Cache. If the result is younger than 1 hour, we return it.
|
||||
let old_items = match cache.get(&url_str) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 60 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
let _ = cache.check().await;
|
||||
return Ok(items.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
let text = requester.get(&url_str, None).await.unwrap();
|
||||
let video_items: Vec<VideoItem> = self
|
||||
.get_video_items_from_html(text.clone(), requester)
|
||||
.await;
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&url_str);
|
||||
cache.insert(url_str.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
async fn get_video_items_from_html(
|
||||
&self,
|
||||
html: String,
|
||||
requester: Requester,
|
||||
) -> Vec<VideoItem> {
|
||||
if html.is_empty() {
|
||||
println!("HTML is empty");
|
||||
return vec![];
|
||||
}
|
||||
let raw_videos = html.split("item list-film-item").collect::<Vec<&str>>()[1..].to_vec();
|
||||
let mut urls: Vec<String> = vec![];
|
||||
for video_segment in &raw_videos {
|
||||
// let vid = video_segment.split("\n").collect::<Vec<&str>>();
|
||||
// for (index, line) in vid.iter().enumerate() {
|
||||
// println!("Line {}: {}", index, line.to_string().trim());
|
||||
// }
|
||||
|
||||
let url_str = format!(
|
||||
"{}{}",
|
||||
self.url,
|
||||
video_segment.split("<a href=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string()
|
||||
);
|
||||
urls.push(url_str.clone());
|
||||
}
|
||||
let futures = urls
|
||||
.into_iter()
|
||||
.map(|el| self.get_video_item(el.clone(), requester.clone()));
|
||||
let results: Vec<Result<VideoItem>> = join_all(futures).await;
|
||||
let video_items: Vec<VideoItem> = results.into_iter().filter_map(Result::ok).collect();
|
||||
|
||||
return video_items;
|
||||
}
|
||||
|
||||
async fn get_video_item(&self, url_str: String, mut requester: Requester) -> Result<VideoItem> {
|
||||
let vid = requester.get(&url_str, None).await.unwrap();
|
||||
let mut title = vid
|
||||
.split("<meta property=\"og:title\" content=\"")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.trim()
|
||||
.to_string();
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
let thumb = format!(
|
||||
"{}{}",
|
||||
self.url,
|
||||
vid.split("<meta property=\"og:image\" content=\"")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string()
|
||||
);
|
||||
|
||||
let video_urls = vid.split("var videoList = ").collect::<Vec<&str>>()[1]
|
||||
.split("\"src\":\"")
|
||||
.collect::<Vec<&str>>()[1..].to_vec();
|
||||
let mut formats = vec![];
|
||||
for url in video_urls {
|
||||
let video_url = url
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.replace("\\", "")
|
||||
.to_string();
|
||||
let format =
|
||||
videos::VideoFormat::new(video_url.clone(), "1080".to_string(), "mp4".to_string())
|
||||
// .protocol("https".to_string())
|
||||
.format_id(video_url.split("/").last().unwrap().to_string())
|
||||
.format_note(format!("{}", video_url.split("_").last().unwrap().replace(".mp4", "").to_string()))
|
||||
;
|
||||
formats.push(format);
|
||||
}
|
||||
|
||||
formats.reverse();
|
||||
let id = url_str
|
||||
.split("/")
|
||||
.collect::<Vec<&str>>()[3]
|
||||
.split("_")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
|
||||
let video_item = VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
url_str.clone(),
|
||||
"paradisehill".to_string(),
|
||||
thumb,
|
||||
0,
|
||||
)
|
||||
.aspect_ratio(0.697674419 as f32)
|
||||
.formats(formats);
|
||||
|
||||
return Ok(video_item);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for ParadisehillProvider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
pool: DbPool,
|
||||
sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
per_page: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let _ = pool;
|
||||
let _ = sort;
|
||||
let _ = per_page;
|
||||
let videos: std::result::Result<Vec<VideoItem>, Error> = match query {
|
||||
Some(q) => {
|
||||
self.query(cache, page.parse::<u8>().unwrap_or(1), &q, options)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
self.get(cache, page.parse::<u8>().unwrap_or(1), options)
|
||||
.await
|
||||
}
|
||||
};
|
||||
match videos {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
println!("Error fetching videos: {}", e);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
317
src/providers/perfectgirls.rs
Normal file
@@ -0,0 +1,317 @@
|
||||
use crate::util::parse_abbreviated_number;
|
||||
use crate::DbPool;
|
||||
use crate::providers::Provider;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::flaresolverr::{FlareSolverrRequest, Flaresolverr};
|
||||
use crate::util::time::parse_time_to_seconds;
|
||||
use crate::videos::{ServerOptions, VideoItem};
|
||||
use error_chain::error_chain;
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
use std::env;
|
||||
use std::vec;
|
||||
use wreq::{Client};
|
||||
use wreq_util::Emulation;
|
||||
use async_trait::async_trait;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PerfectgirlsProvider {
|
||||
url: String,
|
||||
}
|
||||
impl PerfectgirlsProvider {
|
||||
pub fn new() -> Self {
|
||||
PerfectgirlsProvider {
|
||||
url: "https://www.perfectgirls.xxx".to_string(),
|
||||
}
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
sort: &str,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let sort_string = match sort {
|
||||
"trending" => "/trending",
|
||||
"popular" => "/popular",
|
||||
_ => "",
|
||||
};
|
||||
let video_url = format!("{}{}/{}/", self.url, sort_string, page);
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
items.clone()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
// let proxy = Proxy::all("http://192.168.0.103:8081").unwrap();
|
||||
let client = Client::builder().cert_verification(false).emulation(Emulation::Firefox136).build()?;
|
||||
|
||||
let mut response = client.get(video_url.clone())
|
||||
// .proxy(proxy.clone())
|
||||
.send().await?;
|
||||
if response.status().is_redirection(){
|
||||
println!("Redirection detected, following to: {}", response.headers()["Location"].to_str().unwrap());
|
||||
response = client.get(response.headers()["Location"].to_str().unwrap())
|
||||
// .proxy(proxy.clone())
|
||||
.send().await?;
|
||||
}
|
||||
if response.status().is_success() {
|
||||
let text = response.text().await?;
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
} else {
|
||||
let flare_url = env::var("FLARE_URL").expect("FLARE_URL not set");
|
||||
let flare = Flaresolverr::new(flare_url);
|
||||
let result = flare
|
||||
.solve(FlareSolverrRequest {
|
||||
cmd: "request.get".to_string(),
|
||||
url: video_url.clone(),
|
||||
maxTimeout: 60000,
|
||||
})
|
||||
.await;
|
||||
let video_items = match result {
|
||||
Ok(res) => {
|
||||
// println!("FlareSolverr response: {}", res);
|
||||
self.get_video_items_from_html(res.solution.response)
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Error solving FlareSolverr: {}", e);
|
||||
return Err("Failed to solve FlareSolverr".into());
|
||||
}
|
||||
};
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
}
|
||||
async fn query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
query: &str,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let search_string = query.to_lowercase().trim().replace(" ", "-");
|
||||
let mut video_url = format!("{}/search/{}/{}/", self.url, search_string, page);
|
||||
|
||||
if search_string.starts_with("@"){
|
||||
let url_part = search_string.split("@").collect::<Vec<&str>>()[1].replace(":", "/");
|
||||
video_url = format!("{}/{}/", self.url, url_part);
|
||||
}
|
||||
// Check our Video Cache. If the result is younger than 1 hour, we return it.
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
let _ = cache.check().await;
|
||||
return Ok(items.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
// let proxy = Proxy::all("http://192.168.0.103:8081").unwrap();
|
||||
let client = Client::builder().cert_verification(false).emulation(Emulation::Firefox136).build()?;
|
||||
|
||||
let mut response = client.get(video_url.clone())
|
||||
// .proxy(proxy.clone())
|
||||
.send().await?;
|
||||
|
||||
if response.status().is_redirection(){
|
||||
|
||||
response = client.get(self.url.clone() + response.headers()["Location"].to_str().unwrap())
|
||||
// .proxy(proxy.clone())
|
||||
.send().await?;
|
||||
}
|
||||
|
||||
if response.status().is_success() {
|
||||
let text = response.text().await?;
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
} else {
|
||||
let flare_url = env::var("FLARE_URL").expect("FLARE_URL not set");
|
||||
let flare = Flaresolverr::new(flare_url);
|
||||
let result = flare
|
||||
.solve(FlareSolverrRequest {
|
||||
cmd: "request.get".to_string(),
|
||||
url: video_url.clone(),
|
||||
maxTimeout: 60000,
|
||||
})
|
||||
.await;
|
||||
let video_items = match result {
|
||||
Ok(res) => self.get_video_items_from_html(res.solution.response),
|
||||
Err(e) => {
|
||||
println!("Error solving FlareSolverr: {}", e);
|
||||
return Err("Failed to solve FlareSolverr".into());
|
||||
}
|
||||
};
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_video_items_from_html(&self, html: String) -> Vec<VideoItem> {
|
||||
if html.is_empty() {
|
||||
println!("HTML is empty");
|
||||
return vec![];
|
||||
}
|
||||
let mut items: Vec<VideoItem> = Vec::new();
|
||||
let raw_videos = html.split("<div class=\"pagination\"").collect::<Vec<&str>>()[0]
|
||||
.split("item thumb-bl thumb-bl-video video_")
|
||||
.collect::<Vec<&str>>()[1..]
|
||||
.to_vec();
|
||||
for video_segment in &raw_videos {
|
||||
// let vid = video_segment.split("\n").collect::<Vec<&str>>();
|
||||
// for (index, line) in vid.iter().enumerate() {
|
||||
// println!("Line {}: {}", index, line);
|
||||
// }
|
||||
let video_url: String = format!("{}{}", self.url, video_segment.split("<a href=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]);
|
||||
let preview_url = video_segment.split("data-preview-custom=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let mut title = video_segment.split("\" title=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
// html decode
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
let id = video_url.split("/").collect::<Vec<&str>>()[4].to_string();
|
||||
let raw_duration = video_segment.split("fa fa-clock-o").collect::<Vec<&str>>()[1]
|
||||
.split("<span>").collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let duration = parse_time_to_seconds(&raw_duration).unwrap_or(0) as u32;
|
||||
|
||||
let mut thumb = video_segment.split(" class=\"thumb lazy-load\"").collect::<Vec<&str>>()[1]
|
||||
.split("data-original=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
if thumb.starts_with("//"){
|
||||
thumb = format!("https:{}",thumb);
|
||||
}
|
||||
|
||||
let mut tags = vec![];
|
||||
if video_segment.contains("href=\"/channels/"){
|
||||
let raw_tags = video_segment.split("href=\"/channels/").collect::<Vec<&str>>()[1..]
|
||||
.iter()
|
||||
.map(|s| s.split("/\"").collect::<Vec<&str>>()[0].to_string())
|
||||
.collect::<Vec<String>>();
|
||||
for tag in raw_tags {
|
||||
if !tag.is_empty() {
|
||||
tags.push(format!("@channels:{}",tag));
|
||||
}
|
||||
}
|
||||
}
|
||||
if video_segment.contains("href=\"/pornstars/"){
|
||||
let raw_tags = video_segment.split("href=\"/pornstars/").collect::<Vec<&str>>()[1..]
|
||||
.iter()
|
||||
.map(|s| s.split("/\"").collect::<Vec<&str>>()[0].to_string())
|
||||
.collect::<Vec<String>>();
|
||||
for tag in raw_tags {
|
||||
if !tag.is_empty() {
|
||||
tags.push(format!("@pornstars:{}",tag));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let views_part = video_segment.split("fa fa-eye").collect::<Vec<&str>>()[1]
|
||||
.split("<span>").collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let views = parse_abbreviated_number(&views_part).unwrap_or(0) as u32;
|
||||
|
||||
let video_item = VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
video_url.to_string(),
|
||||
"perfectgirls".to_string(),
|
||||
thumb,
|
||||
duration,
|
||||
)
|
||||
.preview(preview_url)
|
||||
.views(views)
|
||||
.tags(tags)
|
||||
;
|
||||
items.push(video_item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for PerfectgirlsProvider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
pool: DbPool,
|
||||
sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
per_page: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let _ = options;
|
||||
let _ = per_page;
|
||||
let _ = pool;
|
||||
let videos: std::result::Result<Vec<VideoItem>, Error> = match query {
|
||||
Some(q) => {
|
||||
self.query(cache, page.parse::<u8>().unwrap_or(1), &q,)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
self.get(cache, page.parse::<u8>().unwrap_or(1), &sort)
|
||||
.await
|
||||
}
|
||||
};
|
||||
match videos {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
println!("Error fetching videos: {}", e);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
533
src/providers/perverzija.rs
Normal file
@@ -0,0 +1,533 @@
|
||||
use crate::DbPool;
|
||||
use crate::db;
|
||||
use crate::providers::Provider;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::time::parse_time_to_seconds;
|
||||
use crate::videos::ServerOptions;
|
||||
use crate::videos::{self, VideoEmbed, VideoItem};
|
||||
use async_trait::async_trait;
|
||||
use error_chain::error_chain;
|
||||
use futures::future::join_all;
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use wreq::Version;
|
||||
use std::vec;
|
||||
use wreq::Client;
|
||||
use wreq_util::Emulation;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
JsonError(serde_json::Error);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct PerverzijaDbEntry {
|
||||
url_string: String,
|
||||
tags_strings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PerverzijaProvider {
|
||||
url: String,
|
||||
}
|
||||
impl PerverzijaProvider {
|
||||
pub fn new() -> Self {
|
||||
PerverzijaProvider {
|
||||
url: "https://tube.perverzija.com/".to_string(),
|
||||
}
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
pool: DbPool,
|
||||
page: u8,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let featured = options.featured.unwrap_or("".to_string());
|
||||
let mut prefix_uri = "".to_string();
|
||||
if featured == "featured" {
|
||||
prefix_uri = "featured-scenes/".to_string();
|
||||
}
|
||||
let mut url_str = format!("{}{}page/{}/", self.url, prefix_uri, page);
|
||||
if page == 1 {
|
||||
url_str = format!("{}{}", self.url, prefix_uri);
|
||||
}
|
||||
|
||||
let old_items = match cache.get(&url_str) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 60 {
|
||||
//println!("Cache hit for URL: {}", url_str);
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
items.clone()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
let text = requester.get(&url_str, Some(Version::HTTP_2)).await.unwrap();
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone(), pool);
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&url_str);
|
||||
cache.insert(url_str.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
async fn query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
pool: DbPool,
|
||||
page: u8,
|
||||
query: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let mut query_parse = true;
|
||||
let search_string = query.replace(" ", "+");
|
||||
let mut url_str = format!("{}page/{}/?s={}", self.url, page, search_string);
|
||||
if page == 1 {
|
||||
url_str = format!("{}?s={}", self.url, search_string);
|
||||
}
|
||||
|
||||
if query.starts_with("@studio:") {
|
||||
let studio_name = query.replace("@studio:", "");
|
||||
url_str = format!("{}studio/{}/page/{}/", self.url, studio_name, page);
|
||||
query_parse = false;
|
||||
} else if query.starts_with("@stars:") {
|
||||
let stars_name = query.replace("@stars:", "");
|
||||
url_str = format!("{}stars/{}/page/{}/", self.url, stars_name, page);
|
||||
query_parse = false;
|
||||
}
|
||||
url_str = url_str.replace("page/1/", "");
|
||||
// Check our Video Cache. If the result is younger than 1 hour, we return it.
|
||||
let old_items = match cache.get(&url_str) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 60 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
let _ = cache.check().await;
|
||||
return Ok(items.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
let text = requester.get(&url_str, Some(Version::HTTP_2)).await.unwrap();
|
||||
let video_items: Vec<VideoItem> = match query_parse {
|
||||
true => {
|
||||
self.get_video_items_from_html_query(text.clone(), pool)
|
||||
.await
|
||||
}
|
||||
false => self.get_video_items_from_html(text.clone(), pool),
|
||||
};
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&url_str);
|
||||
cache.insert(url_str.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
fn get_video_items_from_html(&self, html: String, pool: DbPool) -> Vec<VideoItem> {
|
||||
if html.is_empty() {
|
||||
println!("HTML is empty");
|
||||
return vec![];
|
||||
}
|
||||
let mut items: Vec<VideoItem> = Vec::new();
|
||||
let video_listing_content = html.split("video-listing-content").collect::<Vec<&str>>()[1];
|
||||
let raw_videos = video_listing_content
|
||||
.split("video-item post")
|
||||
.collect::<Vec<&str>>()[1..]
|
||||
.to_vec();
|
||||
for video_segment in &raw_videos {
|
||||
let vid = video_segment.split("\n").collect::<Vec<&str>>();
|
||||
if vid.len() > 20 {
|
||||
continue;
|
||||
}
|
||||
// for (index, line) in vid.iter().enumerate() {
|
||||
// println!("Line {}: {}", index, line.to_string().trim());
|
||||
// }
|
||||
let mut title = vid[1].split(">").collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
// html decode
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
if !vid[1].contains("iframe src="") {
|
||||
continue;
|
||||
}
|
||||
let url_str = vid[1].split("iframe src="").collect::<Vec<&str>>()[1]
|
||||
.split(""")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string()
|
||||
.replace("index.php", "xs1.php");
|
||||
if url_str.starts_with("https://streamtape.com/") {
|
||||
continue; // Skip Streamtape links
|
||||
}
|
||||
let id = url_str.split("data=").collect::<Vec<&str>>()[1]
|
||||
.split("&")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let raw_duration = match vid.len() {
|
||||
10 => vid[6].split("time_dur\">").collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string(),
|
||||
_ => "00:00".to_string(),
|
||||
};
|
||||
let duration = parse_time_to_seconds(&raw_duration).unwrap_or(0) as u32;
|
||||
|
||||
if !vid[4].contains("srcset=")
|
||||
&& vid[4].split("src=\"").collect::<Vec<&str>>().len() == 1
|
||||
{
|
||||
for (index, line) in vid.iter().enumerate() {
|
||||
println!("Line {}: {}\n\n", index, line);
|
||||
}
|
||||
}
|
||||
|
||||
let mut thumb = "".to_string();
|
||||
for v in vid.clone() {
|
||||
let line = v.trim();
|
||||
if line.starts_with("<img ") {
|
||||
thumb = line.split(" src=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
}
|
||||
}
|
||||
let embed_html = vid[1].split("data-embed='").collect::<Vec<&str>>()[1]
|
||||
.split("'")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let id_url = vid[1].split("data-url='").collect::<Vec<&str>>()[1]
|
||||
.split("'")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
|
||||
let mut conn = pool.get().expect("couldn't get db connection from pool");
|
||||
let _ = db::insert_video(&mut conn, &id_url, &url_str);
|
||||
drop(conn);
|
||||
let referer_url = "https://xtremestream.xyz/".to_string();
|
||||
let embed = VideoEmbed::new(embed_html, url_str.clone());
|
||||
|
||||
let mut tags: Vec<String> = Vec::new(); // Placeholder for tags, adjust as needed
|
||||
|
||||
let studios_parts = vid[7].split("a href=\"").collect::<Vec<&str>>();
|
||||
for studio in studios_parts.iter().skip(1) {
|
||||
if studio.starts_with("https://tube.perverzija.com/studio/") {
|
||||
tags.push(
|
||||
studio.split("/\"").collect::<Vec<&str>>()[0]
|
||||
.replace("https://tube.perverzija.com/studio/", "@studio:")
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for tag in vid[0].split(" ").collect::<Vec<&str>>() {
|
||||
if tag.starts_with("stars-") {
|
||||
let tag_name = tag.split("stars-").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
if !tag_name.is_empty() {
|
||||
tags.push(format!("@stars:{}", tag_name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for tag in vid[0].split(" ").collect::<Vec<&str>>() {
|
||||
if tag.starts_with("tag-") {
|
||||
let tag_name = tag.split("tag-").collect::<Vec<&str>>()[1].to_string();
|
||||
if !tag_name.is_empty() {
|
||||
tags.push(tag_name.replace("-", " ").to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut video_item = VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
embed.source.clone(),
|
||||
"perverzija".to_string(),
|
||||
thumb,
|
||||
duration,
|
||||
)
|
||||
.tags(tags);
|
||||
// .embed(embed.clone());
|
||||
let mut format =
|
||||
videos::VideoFormat::new(url_str.clone(), "1080".to_string(), "m3u8".to_string());
|
||||
format.add_http_header("Referer".to_string(), referer_url.clone());
|
||||
if let Some(formats) = video_item.formats.as_mut() {
|
||||
formats.push(format);
|
||||
} else {
|
||||
video_item.formats = Some(vec![format]);
|
||||
}
|
||||
items.push(video_item);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
async fn get_video_items_from_html_query(&self, html: String, pool: DbPool) -> Vec<VideoItem> {
|
||||
let raw_videos = html.split("video-item post").collect::<Vec<&str>>()[1..].to_vec();
|
||||
let futures = raw_videos
|
||||
.into_iter()
|
||||
.map(|el| self.get_video_item(el, pool.clone()));
|
||||
let results: Vec<Result<VideoItem>> = join_all(futures).await;
|
||||
let items: Vec<VideoItem> = results.into_iter().filter_map(Result::ok).collect();
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
async fn get_video_item(&self, snippet: &str, pool: DbPool) -> Result<VideoItem> {
|
||||
let vid = snippet.split("\n").collect::<Vec<&str>>();
|
||||
if vid.len() > 30 {
|
||||
return Err("Unexpected video snippet length".into());
|
||||
}
|
||||
|
||||
let mut title = vid[5].split(" title=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
|
||||
let thumb = match vid[6].split(" src=\"").collect::<Vec<&str>>().len() {
|
||||
1 => {
|
||||
for (index, line) in vid.iter().enumerate() {
|
||||
println!("Line {}: {}", index, line.to_string().trim());
|
||||
}
|
||||
return Err("Failed to parse thumbnail URL".into());
|
||||
}
|
||||
_ => vid[6].split(" src=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string(),
|
||||
};
|
||||
let duration = 0;
|
||||
|
||||
let lookup_url = vid[5].split(" href=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let referer_url = "https://xtremestream.xyz/".to_string();
|
||||
|
||||
let mut conn = pool.get().expect("couldn't get db connection from pool");
|
||||
let db_result = db::get_video(&mut conn, lookup_url.clone());
|
||||
match db_result {
|
||||
Ok(Some(entry)) => {
|
||||
if entry.starts_with("{") {
|
||||
// replace old urls with new json objects
|
||||
let entry = serde_json::from_str::<PerverzijaDbEntry>(entry.as_str())?;
|
||||
let url_str = entry.url_string;
|
||||
let tags = entry.tags_strings;
|
||||
if url_str.starts_with("!") {
|
||||
return Err("Video was removed".into());
|
||||
}
|
||||
let mut id = url_str.split("data=").collect::<Vec<&str>>()[1].to_string();
|
||||
if id.contains("&") {
|
||||
id = id.split("&").collect::<Vec<&str>>()[0].to_string()
|
||||
}
|
||||
let mut video_item = VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
url_str.clone(),
|
||||
"perverzija".to_string(),
|
||||
thumb,
|
||||
duration,
|
||||
)
|
||||
.tags(tags);
|
||||
let mut format = videos::VideoFormat::new(
|
||||
url_str.clone(),
|
||||
"1080".to_string(),
|
||||
"m3u8".to_string(),
|
||||
);
|
||||
format.add_http_header("Referer".to_string(), referer_url.clone());
|
||||
if let Some(formats) = video_item.formats.as_mut() {
|
||||
formats.push(format);
|
||||
} else {
|
||||
video_item.formats = Some(vec![format]);
|
||||
}
|
||||
return Ok(video_item);
|
||||
} else {
|
||||
let _ = db::delete_video(&mut conn, lookup_url.clone());
|
||||
};
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
println!("Error fetching video from database: {}", e);
|
||||
// return Err(format!("Error fetching video from database: {}", e).into());
|
||||
}
|
||||
}
|
||||
drop(conn);
|
||||
|
||||
let client = Client::builder().emulation(Emulation::Firefox136).build()?;
|
||||
|
||||
let response = client.get(lookup_url.clone()).send().await?;
|
||||
let text = match response.status().is_success() {
|
||||
true => response.text().await?,
|
||||
false => {
|
||||
println!("Failed to fetch video details");
|
||||
return Err("Failed to fetch video details".into());
|
||||
}
|
||||
};
|
||||
|
||||
let mut url_str = text.split("<iframe src=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string()
|
||||
.replace("index.php", "xs1.php");
|
||||
if !url_str.contains("xtremestream.xyz") {
|
||||
url_str = "!".to_string()
|
||||
}
|
||||
|
||||
let mut tags: Vec<String> = Vec::new(); // Placeholder for tags, adjust as needed
|
||||
|
||||
let studios_parts = text
|
||||
.split("<strong>Studio: </strong>")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("</div>")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.split("<a href=\"")
|
||||
.collect::<Vec<&str>>();
|
||||
for studio in studios_parts.iter().skip(1) {
|
||||
if studio.starts_with("https://tube.perverzija.com/studio/") {
|
||||
tags.push(
|
||||
studio.split("/\"").collect::<Vec<&str>>()[0]
|
||||
.replace("https://tube.perverzija.com/studio/", "@studio:")
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if text.contains("<strong>Stars: </strong>") {
|
||||
let stars_parts: Vec<&str> = text
|
||||
.split("<strong>Stars: </strong>")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("</div>")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.split("<a href=\"")
|
||||
.collect::<Vec<&str>>();
|
||||
for star in stars_parts.iter().skip(1) {
|
||||
if star.starts_with("https://tube.perverzija.com/stars/") {
|
||||
tags.push(
|
||||
star.split("/\"").collect::<Vec<&str>>()[0]
|
||||
.replace("https://tube.perverzija.com/stars/", "@stars:")
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tags_parts: Vec<&str> = text.split("<strong>Tags: </strong>").collect::<Vec<&str>>()[1]
|
||||
.split("</div>")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.split("<a href=\"")
|
||||
.collect::<Vec<&str>>();
|
||||
for star in tags_parts.iter().skip(1) {
|
||||
if star.starts_with("https://tube.perverzija.com/stars/") {
|
||||
tags.push(
|
||||
star.split("/\"").collect::<Vec<&str>>()[0]
|
||||
.replace("https://tube.perverzija.com/stars/", "@stars:")
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let perverzija_db_entry = PerverzijaDbEntry {
|
||||
url_string: url_str.clone(),
|
||||
tags_strings: tags.clone(),
|
||||
};
|
||||
let mut conn = pool.get().expect("couldn't get db connection from pool");
|
||||
let insert_result = db::insert_video(
|
||||
&mut conn,
|
||||
&lookup_url,
|
||||
&serde_json::to_string(&perverzija_db_entry)?,
|
||||
);
|
||||
match insert_result {
|
||||
Ok(_) => (),
|
||||
Err(e) => {
|
||||
println!("{:?}", e);
|
||||
}
|
||||
}
|
||||
drop(conn);
|
||||
if !url_str.contains("xtremestream.xyz") {
|
||||
return Err("Video URL does not contain xtremestream.xyz".into());
|
||||
}
|
||||
let mut id = url_str.split("data=").collect::<Vec<&str>>()[1].to_string();
|
||||
if id.contains("&") {
|
||||
id = id.split("&").collect::<Vec<&str>>()[0].to_string()
|
||||
}
|
||||
// if !vid[6].contains(" src=\""){
|
||||
// for (index,line) in vid.iter().enumerate() {
|
||||
// println!("Line {}: {}", index, line.to_string().trim());
|
||||
// }
|
||||
// }
|
||||
// for (index, line) in vid.iter().enumerate() {
|
||||
// println!("Line {}: {}", index, line.to_string().trim());
|
||||
// }
|
||||
|
||||
let mut video_item = VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
url_str.clone(),
|
||||
"perverzija".to_string(),
|
||||
thumb,
|
||||
duration,
|
||||
)
|
||||
.tags(tags);
|
||||
// .embed(embed.clone());
|
||||
let mut format =
|
||||
videos::VideoFormat::new(url_str.clone(), "1080".to_string(), "m3u8".to_string());
|
||||
format.add_http_header("Referer".to_string(), referer_url.clone());
|
||||
if let Some(formats) = video_item.formats.as_mut() {
|
||||
formats.push(format);
|
||||
} else {
|
||||
video_item.formats = Some(vec![format]);
|
||||
}
|
||||
return Ok(video_item);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for PerverzijaProvider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
pool: DbPool,
|
||||
sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
per_page: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let _ = per_page;
|
||||
let _ = sort;
|
||||
let videos: std::result::Result<Vec<VideoItem>, Error> = match query {
|
||||
Some(q) => {
|
||||
self.query(cache, pool, page.parse::<u8>().unwrap_or(1), &q, options)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
self.get(cache, pool, page.parse::<u8>().unwrap_or(1), options)
|
||||
.await
|
||||
}
|
||||
};
|
||||
match videos {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
println!("Error fetching videos: {}", e);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
537
src/providers/pimpbunny.rs
Normal file
@@ -0,0 +1,537 @@
|
||||
use crate::DbPool;
|
||||
use crate::api::ClientVersion;
|
||||
use crate::providers::Provider;
|
||||
use crate::status::*;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::discord::{format_error_chain, send_discord_error_report};
|
||||
use crate::util::requester::Requester;
|
||||
use crate::util::time::parse_time_to_seconds;
|
||||
use crate::videos::{ServerOptions, VideoFormat, VideoItem};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use error_chain::error_chain;
|
||||
use futures::future::join_all;
|
||||
use htmlentity::entity::{decode, ICodedDataTrait};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::{thread, vec};
|
||||
use titlecase::Titlecase;
|
||||
use wreq::Version;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
Json(serde_json::Error);
|
||||
}
|
||||
errors {
|
||||
Parse(msg: String) {
|
||||
description("parse error")
|
||||
display("parse error: {}", msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PimpbunnyProvider {
|
||||
url: String,
|
||||
stars: Arc<RwLock<Vec<FilterOption>>>,
|
||||
categories: Arc<RwLock<Vec<FilterOption>>>,
|
||||
}
|
||||
|
||||
impl PimpbunnyProvider {
|
||||
pub fn new() -> Self {
|
||||
let provider = Self {
|
||||
url: "https://pimpbunny.com".to_string(),
|
||||
stars: Arc::new(RwLock::new(vec![])),
|
||||
categories: Arc::new(RwLock::new(vec![])),
|
||||
};
|
||||
provider.spawn_initial_load();
|
||||
provider
|
||||
}
|
||||
|
||||
fn build_channel(&self, clientversion: ClientVersion) -> Channel {
|
||||
let _ = clientversion;
|
||||
Channel {
|
||||
id: "pimpbunny".to_string(),
|
||||
name: "Pimpbunny".to_string(),
|
||||
description: "Watch Porn!".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=pimpbunny.com".to_string(),
|
||||
status: "active".to_string(),
|
||||
categories: self
|
||||
.categories
|
||||
.read()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| c.title.clone())
|
||||
.collect(),
|
||||
options: vec![ChannelOption {
|
||||
id: "sort".to_string(),
|
||||
title: "Sort".to_string(),
|
||||
description: "Sort the Videos".to_string(),
|
||||
systemImage: "list.number".to_string(),
|
||||
colorName: "blue".to_string(),
|
||||
options: vec![
|
||||
FilterOption {
|
||||
id: "featured".into(),
|
||||
title: "Featured".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "most recent".into(),
|
||||
title: "Most Recent".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "most viewed".into(),
|
||||
title: "Most Viewed".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "best rated".into(),
|
||||
title: "Best Rated".into(),
|
||||
},
|
||||
],
|
||||
multiSelect: false,
|
||||
}],
|
||||
nsfw: true,
|
||||
cacheDuration: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_initial_load(&self) {
|
||||
let url = self.url.clone();
|
||||
let stars = Arc::clone(&self.stars);
|
||||
let categories = Arc::clone(&self.categories);
|
||||
|
||||
thread::spawn(async move || {
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
eprintln!("tokio runtime failed: {e}");
|
||||
send_discord_error_report(
|
||||
e.to_string(),
|
||||
Some(format_error_chain(&e)),
|
||||
Some("Pimpbunny Provider"),
|
||||
Some("Failed to create tokio runtime"),
|
||||
file!(),
|
||||
line!(),
|
||||
module_path!(),
|
||||
).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
rt.block_on(async {
|
||||
if let Err(e) = Self::load_stars(&url, Arc::clone(&stars)).await {
|
||||
eprintln!("load_stars failed: {e}");
|
||||
send_discord_error_report(
|
||||
e.to_string(),
|
||||
Some(format_error_chain(&e)),
|
||||
Some("Pimpbunny Provider"),
|
||||
Some("Failed to load stars during initial load"),
|
||||
file!(),
|
||||
line!(),
|
||||
module_path!(),
|
||||
).await;
|
||||
}
|
||||
if let Err(e) = Self::load_categories(&url, Arc::clone(&categories)).await {
|
||||
eprintln!("load_categories failed: {e}");
|
||||
send_discord_error_report(
|
||||
e.to_string(),
|
||||
Some(format_error_chain(&e)),
|
||||
Some("Pimpbunny Provider"),
|
||||
Some("Failed to load categories during initial load"),
|
||||
file!(),
|
||||
line!(),
|
||||
module_path!(),
|
||||
).await;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn push_unique(target: &Arc<RwLock<Vec<FilterOption>>>, item: FilterOption) {
|
||||
if let Ok(mut vec) = target.write() {
|
||||
if !vec.iter().any(|x| x.id == item.id) {
|
||||
vec.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_stars(base: &str, stars: Arc<RwLock<Vec<FilterOption>>>) -> Result<()> {
|
||||
let mut requester = Requester::new();
|
||||
let text = requester
|
||||
.get(
|
||||
&format!("{base}/onlyfans-models/?models_per_page=20"),
|
||||
Some(Version::HTTP_2),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| Error::from(format!("{}", e)))?;
|
||||
|
||||
let block = text
|
||||
.split("vt_list_models_with_advertising_custom_models_list_items")
|
||||
.last()
|
||||
.ok_or_else(|| ErrorKind::Parse("missing stars block".into()))?
|
||||
.split("pb-page-description")
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
|
||||
for el in block.split("<div class=\"col\">").skip(1) {
|
||||
if el.contains("pb-promoted-link") || !el.contains("href=\"https://pimpbunny.com/onlyfans-models/") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let id = el
|
||||
.split("href=\"https://pimpbunny.com/onlyfans-models/")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split("/\"").next())
|
||||
.ok_or_else(|| ErrorKind::Parse(format!("star id: {el}").into()))?
|
||||
.to_string();
|
||||
|
||||
let title = el
|
||||
.split("ui-card-title")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('<').next())
|
||||
.ok_or_else(|| ErrorKind::Parse(format!("star title: {el}").into()))?
|
||||
.to_string();
|
||||
|
||||
Self::push_unique(&stars, FilterOption { id, title });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_categories(base: &str, cats: Arc<RwLock<Vec<FilterOption>>>) -> Result<()> {
|
||||
let mut requester = Requester::new();
|
||||
let text = requester
|
||||
.get(
|
||||
&format!("{base}/categories/?items_per_page=120"),
|
||||
Some(Version::HTTP_2),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| Error::from(format!("{}", e)))?;
|
||||
|
||||
let block = text
|
||||
.split("list_categories_categories_list_items")
|
||||
.last()
|
||||
.ok_or_else(|| ErrorKind::Parse("categories block".into()))?
|
||||
.split("pb-pagination-wrapper")
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
|
||||
for el in block.split("<div class=\"col\">").skip(1) {
|
||||
let id = el
|
||||
.split("href=\"https://pimpbunny.com/categories/")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split("/\"").next())
|
||||
.ok_or_else(|| ErrorKind::Parse(format!("category id: {el}").into()))?
|
||||
.to_string();
|
||||
|
||||
let title = el
|
||||
.split("ui-heading-h3")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('<').next())
|
||||
.ok_or_else(|| ErrorKind::Parse(format!("category title: {el}").into()))?
|
||||
.titlecase();
|
||||
|
||||
Self::push_unique(&cats, FilterOption { id, title });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
sort: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let sort_string = match sort {
|
||||
"best rated" => "&sort_by=rating",
|
||||
"most viewed" => "&sort_by=video_viewed",
|
||||
_ => "&sort_by=post_date",
|
||||
};
|
||||
let video_url = format!(
|
||||
"{}/videos/{}/?videos_per_page=20{}",
|
||||
self.url, page, sort_string
|
||||
);
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
items.clone()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
let text = requester.get(&video_url, Some(Version::HTTP_11)).await.unwrap();
|
||||
let video_items: Vec<VideoItem> = self
|
||||
.get_video_items_from_html(text.clone(), &mut requester)
|
||||
.await;
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
async fn query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
query: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let search_string = query.trim().to_string();
|
||||
|
||||
let mut video_url = format!(
|
||||
"{}/search/{}/?mode=async&function=get_block&block_id=list_videos_videos_list_search_result&videos_per_page=20&from_videos={}",
|
||||
self.url, search_string.replace(" ","-"), page
|
||||
);
|
||||
|
||||
let sort_string = match options.sort.as_deref().unwrap_or("") {
|
||||
"best rated" => "&sort_by=rating",
|
||||
"most viewed" => "&sort_by=video_viewed",
|
||||
_ => "&sort_by=post_date",
|
||||
};
|
||||
if let Some(star) = self
|
||||
.stars
|
||||
.read()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|s| s.title.to_ascii_lowercase() == search_string.to_ascii_lowercase())
|
||||
{
|
||||
video_url = format!(
|
||||
"{}/onlyfans-models/{}/{}/?videos_per_page=20{}",
|
||||
self.url, star.id, page, sort_string
|
||||
);
|
||||
}
|
||||
if let Some(cat) = self
|
||||
.categories
|
||||
.read()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|c| c.title.to_ascii_lowercase() == search_string.to_ascii_lowercase())
|
||||
{
|
||||
video_url = format!(
|
||||
"{}/categories/{}/{}/?videos_per_page=20{}",
|
||||
self.url, cat.id, page, sort_string
|
||||
);
|
||||
}
|
||||
// Check our Video Cache. If the result is younger than 1 hour, we return it.
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
let _ = cache.check().await;
|
||||
return Ok(items.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
|
||||
let text = requester.get(&video_url, Some(Version::HTTP_2)).await.unwrap();
|
||||
let video_items: Vec<VideoItem> = self
|
||||
.get_video_items_from_html(text.clone(), &mut requester)
|
||||
.await;
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
async fn get_video_items_from_html(
|
||||
&self,
|
||||
html: String,
|
||||
requester: &mut Requester,
|
||||
) -> Vec<VideoItem> {
|
||||
if html.is_empty() || html.contains("404 Not Found") {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let block = match html
|
||||
.split("-pagination-wrapper")
|
||||
.next()
|
||||
.and_then(|s| s.split("video_list").nth(2))
|
||||
{
|
||||
Some(b) => b,
|
||||
None => return vec![],
|
||||
};
|
||||
|
||||
let futures = block
|
||||
.split("<div class=\"col\">")
|
||||
.skip(1)
|
||||
.map(|el| self.get_video_item(el.to_string(), requester.clone()));
|
||||
|
||||
join_all(futures)
|
||||
.await
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn get_video_item(
|
||||
&self,
|
||||
seg: String,
|
||||
mut requester: Requester,
|
||||
) -> Result<VideoItem> {
|
||||
let video_url = seg
|
||||
.split(" href=\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
.ok_or_else(|| ErrorKind::Parse("video url".into()))?
|
||||
.to_string();
|
||||
|
||||
let mut title = seg
|
||||
.split("card-title")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('>').nth(1))
|
||||
.and_then(|s| s.split('<').next())
|
||||
.ok_or_else(|| ErrorKind::Parse("video title".into()))?
|
||||
.trim()
|
||||
.to_string();
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title).titlecase();
|
||||
|
||||
let id = video_url
|
||||
.split('/')
|
||||
.nth(4)
|
||||
.and_then(|s| s.split('.').next())
|
||||
.ok_or_else(|| ErrorKind::Parse("video id".into()))?
|
||||
.to_string();
|
||||
|
||||
let thumb_block = seg
|
||||
.split("card-thumbnail")
|
||||
.nth(1)
|
||||
.ok_or_else(|| ErrorKind::Parse("thumb block".into()))?;
|
||||
|
||||
let mut thumb = thumb_block
|
||||
.split("src=\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
if thumb.starts_with("data:image") {
|
||||
thumb = thumb_block
|
||||
.split("data-webp=\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
}
|
||||
|
||||
let preview = thumb_block
|
||||
.split("data-preview=\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let (tags, formats, views, duration) =
|
||||
self.extract_media(&video_url, &mut requester).await?;
|
||||
|
||||
Ok(VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
video_url,
|
||||
"pimpbunny".into(),
|
||||
thumb,
|
||||
duration,
|
||||
)
|
||||
.formats(formats)
|
||||
.tags(tags)
|
||||
.preview(preview)
|
||||
.views(views))
|
||||
}
|
||||
|
||||
async fn extract_media(
|
||||
&self,
|
||||
url: &str,
|
||||
requester: &mut Requester,
|
||||
) -> Result<(Vec<String>, Vec<VideoFormat>, u32, u32)> {
|
||||
let text = requester
|
||||
.get(url, Some(Version::HTTP_2))
|
||||
.await
|
||||
.map_err(|e| Error::from(format!("{}", e)))?;
|
||||
|
||||
let json_str = text
|
||||
.split("application/ld+json\">")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split("</script>").next())
|
||||
.ok_or_else(|| ErrorKind::Parse("ld+json".into()))?;
|
||||
|
||||
let json: serde_json::Value = serde_json::from_str(json_str)?;
|
||||
|
||||
let video_url = json["contentUrl"].as_str().unwrap_or("").to_string();
|
||||
let quality = video_url
|
||||
.split('_')
|
||||
.last()
|
||||
.and_then(|s| s.split('.').next())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let views = json["interactionStatistic"]
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.and_then(|v| v["userInteractionCount"].as_str())
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let duration = json["duration"]
|
||||
.as_str()
|
||||
.map(|d| parse_time_to_seconds(&d.replace(['P','T','H','M','S'], "")).unwrap_or(0))
|
||||
.unwrap_or(0) as u32;
|
||||
|
||||
Ok((
|
||||
vec![],
|
||||
vec![VideoFormat::new(video_url, quality, "video/mp4".into())],
|
||||
views,
|
||||
duration,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for PimpbunnyProvider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
_pool: DbPool,
|
||||
sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
_per_page: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let page = page.parse::<u8>().unwrap_or(1);
|
||||
|
||||
let res = match query {
|
||||
Some(q) => self.to_owned().query(cache, page, &q, options).await,
|
||||
None => self.get(cache, page, &sort, options).await,
|
||||
};
|
||||
|
||||
res.unwrap_or_else(|e| {
|
||||
eprintln!("pimpbunny error: {e}");
|
||||
vec![]
|
||||
})
|
||||
}
|
||||
|
||||
fn get_channel(&self, v: ClientVersion) -> Option<Channel> {
|
||||
Some(self.build_channel(v))
|
||||
}
|
||||
}
|
||||
302
src/providers/pmvhaven.rs
Normal file
@@ -0,0 +1,302 @@
|
||||
use crate::DbPool;
|
||||
use crate::api::ClientVersion;
|
||||
use crate::providers::Provider;
|
||||
use crate::status::*;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::discord::send_discord_error_report;
|
||||
use crate::util::time::parse_time_to_seconds;
|
||||
use crate::videos::{ServerOptions, VideoItem};
|
||||
use async_trait::async_trait;
|
||||
use error_chain::error_chain;
|
||||
use htmlentity::entity::{decode, ICodedDataTrait};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::vec;
|
||||
use std::fmt::Write;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PmvhavenProvider {
|
||||
url: String,
|
||||
stars: Arc<RwLock<Vec<String>>>,
|
||||
categories: Arc<RwLock<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl PmvhavenProvider {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
url: "https://pmvhaven.com".to_string(),
|
||||
stars: Arc::new(RwLock::new(vec![])),
|
||||
categories: Arc::new(RwLock::new(vec![])),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_channel(&self, clientversion: ClientVersion) -> Channel {
|
||||
let _ = clientversion;
|
||||
|
||||
let categories = self
|
||||
.categories
|
||||
.read()
|
||||
.map(|g| g.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
Channel {
|
||||
id: "pmvhaven".to_string(),
|
||||
name: "PMVHaven".to_string(),
|
||||
description: "Best PMV Videos".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=pmvhaven.com".to_string(),
|
||||
status: "active".to_string(),
|
||||
categories,
|
||||
options: vec![
|
||||
ChannelOption {
|
||||
id: "sort".into(),
|
||||
title: "Sort".into(),
|
||||
description: "Sort the Videos".into(),
|
||||
systemImage: "list.number".into(),
|
||||
colorName: "blue".into(),
|
||||
options: vec![
|
||||
FilterOption {
|
||||
id: "relevance".into(),
|
||||
title: "Relevance".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "newest".into(),
|
||||
title: "Newest".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "oldest".into(),
|
||||
title: "Oldest".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "most viewed".into(),
|
||||
title: "Most Viewed".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "most liked".into(),
|
||||
title: "Most Liked".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "most disliked".into(),
|
||||
title: "Most Disliked".into(),
|
||||
},
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
ChannelOption {
|
||||
id: "duration".into(),
|
||||
title: "Duration".into(),
|
||||
description: "Length of the Videos".into(),
|
||||
systemImage: "timer".into(),
|
||||
colorName: "green".into(),
|
||||
options: vec![
|
||||
FilterOption {
|
||||
id: "any".into(),
|
||||
title: "Any".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "<4 min".into(),
|
||||
title: "<4 min".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "4-20 min".into(),
|
||||
title: "4-20 min".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "20-60 min".into(),
|
||||
title: "20-60 min".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: ">1 hour".into(),
|
||||
title: ">1 hour".into(),
|
||||
},
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
nsfw: true,
|
||||
cacheDuration: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn push_unique(target: &Arc<RwLock<Vec<String>>>, item: String) {
|
||||
if let Ok(mut vec) = target.write() {
|
||||
if !vec.iter().any(|x| x == &item) {
|
||||
vec.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
query: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let search = query.trim().to_string();
|
||||
|
||||
let sort = match options.sort.as_deref() {
|
||||
Some("newest") => "&sort=-uploadDate",
|
||||
Some("oldest") => "&sort=uploadDate",
|
||||
Some("most viewed") => "&sort=-views",
|
||||
Some("most liked") => "&sort=-likes",
|
||||
Some("most disliked") => "&sort=-dislikes",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
let duration = match options.duration.as_deref() {
|
||||
Some("<4 min") => "&durationMax=240",
|
||||
Some("4-20 min") => "&durationMin=240&durationMax=1200",
|
||||
Some("20-60 min") => "&durationMin=1200&durationMax=3600",
|
||||
Some(">1 hour") => "&durationMin=3600",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
let endpoint = if search.is_empty() {
|
||||
"api/videos"
|
||||
} else {
|
||||
"api/videos/search"
|
||||
};
|
||||
|
||||
let mut url = format!(
|
||||
"{}/{endpoint}?limit=100&page={page}{duration}{sort}",
|
||||
self.url
|
||||
);
|
||||
|
||||
if let Ok(stars) = self.stars.read() {
|
||||
if let Some(star) = stars.iter().find(|s| s.eq_ignore_ascii_case(&search)) {
|
||||
url.push_str(&format!("&stars={star}"));
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(cats) = self.categories.read() {
|
||||
if let Some(cat) = cats.iter().find(|c| c.eq_ignore_ascii_case(&search)) {
|
||||
url.push_str(&format!("&tagMode=OR&tags={cat}&expandTags=false"));
|
||||
}
|
||||
}
|
||||
|
||||
if !search.is_empty() {
|
||||
url.push_str(&format!("&q={search}"));
|
||||
}
|
||||
if let Some((time, items)) = cache.get(&url) {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 300 {
|
||||
return Ok(items.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let mut requester = match options.requester {
|
||||
Some(r) => r,
|
||||
None => return Ok(vec![]),
|
||||
};
|
||||
|
||||
let text = requester.get(&url, None).await.unwrap_or_default();
|
||||
let json = serde_json::from_str(&text).unwrap_or(serde_json::Value::Null);
|
||||
let items = self.get_video_items_from_json(json).await;
|
||||
|
||||
if !items.is_empty() {
|
||||
cache.remove(&url);
|
||||
cache.insert(url, items.clone());
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
async fn get_video_items_from_json(&self, json: serde_json::Value) -> Vec<VideoItem> {
|
||||
let mut items = vec![];
|
||||
|
||||
if !json.get("success").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
return items;
|
||||
}
|
||||
|
||||
let videos = json.get("data").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
|
||||
for video in videos {
|
||||
let title = decode(video.get("title").and_then(|v| v.as_str()).unwrap_or("").as_bytes())
|
||||
.to_string()
|
||||
.unwrap_or_default();
|
||||
|
||||
let id = video
|
||||
.get("_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&title)
|
||||
.to_string();
|
||||
|
||||
let video_url = video.get("videoUrl").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let thumb = video.get("thumbnailUrl").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let preview = video.get("previewUrl").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
|
||||
let views = video.get("views").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let duration = parse_time_to_seconds(video.get("duration").and_then(|v| v.as_str()).unwrap_or("0")).unwrap_or(0);
|
||||
|
||||
let tags = video.get("tags").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
let stars = video.get("starsTags").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
for t in tags.iter() {
|
||||
if let Some(s) = t.as_str() {
|
||||
let decoded = decode(s.as_bytes()).to_string().unwrap_or_default();
|
||||
Self::push_unique(&self.categories, decoded.clone());
|
||||
}
|
||||
}
|
||||
for t in stars.iter() {
|
||||
if let Some(s) = t.as_str() {
|
||||
let decoded = decode(s.as_bytes()).to_string().unwrap_or_default();
|
||||
Self::push_unique(&self.stars, decoded.clone());
|
||||
}
|
||||
}
|
||||
|
||||
items.push(
|
||||
VideoItem::new(id, title, video_url.replace(' ', "%20"), "pmvhaven".into(), thumb, duration as u32)
|
||||
.views(views as u32)
|
||||
.preview(preview)
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for PmvhavenProvider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
_pool: DbPool,
|
||||
_sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
_per_page: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let page = page.parse::<u8>().unwrap_or(1);
|
||||
let query = query.unwrap_or_default();
|
||||
|
||||
match self.query(cache, page, &query, options).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("pmvhaven error: {e}");
|
||||
let mut chain_str = String::new();
|
||||
for (i, cause) in e.iter().enumerate() {
|
||||
let _ = writeln!(chain_str, "{}. {}", i + 1, cause);
|
||||
}
|
||||
send_discord_error_report(
|
||||
e.to_string(),
|
||||
Some(chain_str),
|
||||
Some("PMVHaven Provider"),
|
||||
Some("Failed to load videos from PMVHaven"),
|
||||
file!(),
|
||||
line!(),
|
||||
module_path!(),
|
||||
).await;
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
||||
Some(self.build_channel(clientversion))
|
||||
}
|
||||
}
|
||||
205
src/providers/porn00.rs
Normal file
@@ -0,0 +1,205 @@
|
||||
use crate::util::parse_abbreviated_number;
|
||||
use crate::DbPool;
|
||||
use crate::providers::Provider;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::time::parse_time_to_seconds;
|
||||
use crate::videos::{ServerOptions, VideoItem};
|
||||
use error_chain::error_chain;
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
use std::vec;
|
||||
use async_trait::async_trait;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Porn00Provider {
|
||||
url: String,
|
||||
}
|
||||
impl Porn00Provider {
|
||||
pub fn new() -> Self {
|
||||
Porn00Provider {
|
||||
url: "https://www.porn00.org".to_string(),
|
||||
}
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
sort: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let sort_string = match sort {
|
||||
"popular" => "/popular-vids",
|
||||
"top-rated" => "/top-vids",
|
||||
_ => "/latest-vids",
|
||||
};
|
||||
|
||||
let list_str = match sort {
|
||||
"popular" => "list_videos_common_videos_list",
|
||||
"top-rated" => "list_videos_common_videos_list",
|
||||
_ => "list_videos_most_recent_videos",
|
||||
};
|
||||
|
||||
let video_url = format!("{}{}/?mode=async^&function=get_block^&block_id={}^&from={}", self.url, sort_string, list_str, page);
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
items.clone()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
|
||||
let text = requester.get(&video_url, None).await.unwrap();
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
async fn query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
query: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let search_string = query.to_lowercase().trim().replace(" ", "-");
|
||||
let video_url = format!("{}/q/{}/?mode=async&function=get_block&block_id=list_videos_videos_list_search_result&q={}&category_ids=&sort_by=post_date&from_videos={}&from_albums={}&", self.url, search_string, search_string, page, page);
|
||||
// Check our Video Cache. If the result is younger than 1 hour, we return it.
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
let _ = cache.check().await;
|
||||
return Ok(items.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
|
||||
let text = requester.get(&video_url, None).await.unwrap();
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
fn get_video_items_from_html(&self, html: String) -> Vec<VideoItem> {
|
||||
if html.is_empty() {
|
||||
println!("HTML is empty");
|
||||
return vec![];
|
||||
}
|
||||
let mut items: Vec<VideoItem> = Vec::new();
|
||||
let raw_videos = html.split("<div class=\"pagination\"").collect::<Vec<&str>>()[0]
|
||||
.split("<div class=\"item \">")
|
||||
.collect::<Vec<&str>>()[1..]
|
||||
.to_vec();
|
||||
for video_segment in &raw_videos {
|
||||
// let vid = video_segment.split("\n").collect::<Vec<&str>>();
|
||||
// for (index, line) in vid.iter().enumerate() {
|
||||
// println!("Line {}: {}", index, line);
|
||||
// }
|
||||
let video_url: String = video_segment.split("<a href=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0].to_string();
|
||||
let mut title = video_segment.split("\" title=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
// html decode
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
let id = video_url.split("/").collect::<Vec<&str>>()[4].to_string();
|
||||
let raw_duration = video_segment.split("<div class=\"duration\">").collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let duration = parse_time_to_seconds(&raw_duration).unwrap_or(0) as u32;
|
||||
|
||||
let thumb = video_segment.split("<img class=\"thumb ").collect::<Vec<&str>>()[1]
|
||||
.split("data-original=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
|
||||
let views_part = video_segment.split("<div class=\"views\">").collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let views = parse_abbreviated_number(&views_part).unwrap_or(0) as u32;
|
||||
|
||||
let video_item = VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
video_url.to_string(),
|
||||
"porn00".to_string(),
|
||||
thumb,
|
||||
duration,
|
||||
)
|
||||
.views(views)
|
||||
;
|
||||
items.push(video_item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for Porn00Provider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
pool: DbPool,
|
||||
sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
per_page: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let _ = per_page;
|
||||
let _ = pool;
|
||||
let videos: std::result::Result<Vec<VideoItem>, Error> = match query {
|
||||
Some(q) => {
|
||||
self.query(cache, page.parse::<u8>().unwrap_or(1), &q,options)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
self.get(cache, page.parse::<u8>().unwrap_or(1), &sort, options)
|
||||
.await
|
||||
}
|
||||
};
|
||||
match videos {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
println!("Error fetching videos: {}", e);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
230
src/providers/pornhat.rs
Normal file
@@ -0,0 +1,230 @@
|
||||
use crate::util::parse_abbreviated_number;
|
||||
use crate::DbPool;
|
||||
use crate::providers::Provider;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::time::parse_time_to_seconds;
|
||||
use crate::videos::{ServerOptions, VideoItem};
|
||||
use error_chain::error_chain;
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
use std::vec;
|
||||
use async_trait::async_trait;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PornhatProvider {
|
||||
url: String,
|
||||
}
|
||||
impl PornhatProvider {
|
||||
pub fn new() -> Self {
|
||||
PornhatProvider {
|
||||
url: "https://www.pornhat.com".to_string(),
|
||||
}
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
sort: &str,
|
||||
options:ServerOptions
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let sort_string = match sort {
|
||||
"trending" => "/trending",
|
||||
"popular" => "/popular",
|
||||
_ => "",
|
||||
};
|
||||
let video_url = format!("{}{}/{}/", self.url, sort_string, page);
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
items.clone()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
let text = requester.get(&video_url, None).await.unwrap();
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
async fn query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
query: &str,
|
||||
options:ServerOptions
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let search_string = query.to_lowercase().trim().replace(" ", "-");
|
||||
let mut video_url = format!("{}/search/{}/{}/", self.url, search_string, page);
|
||||
|
||||
if search_string.starts_with("@"){
|
||||
let url_part = search_string.split("@").collect::<Vec<&str>>()[1].replace(":", "/");
|
||||
video_url = format!("{}/{}/", self.url, url_part);
|
||||
}
|
||||
// Check our Video Cache. If the result is younger than 1 hour, we return it.
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
let _ = cache.check().await;
|
||||
return Ok(items.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
let text = requester.get(&video_url, None).await.unwrap();
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
fn get_video_items_from_html(&self, html: String) -> Vec<VideoItem> {
|
||||
if html.is_empty() {
|
||||
println!("HTML is empty");
|
||||
return vec![];
|
||||
}
|
||||
let mut items: Vec<VideoItem> = Vec::new();
|
||||
let raw_videos = html.split("<div class=\"pagination\"").collect::<Vec<&str>>()[0]
|
||||
.split("item thumb-bl thumb-bl-video video_")
|
||||
.collect::<Vec<&str>>()[1..]
|
||||
.to_vec();
|
||||
for video_segment in &raw_videos {
|
||||
// let vid = video_segment.split("\n").collect::<Vec<&str>>();
|
||||
// for (index, line) in vid.iter().enumerate() {
|
||||
// println!("Line {}: {}", index, line);
|
||||
// }
|
||||
let video_url: String = format!("{}{}", self.url, video_segment.split("<a href=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]);
|
||||
let preview_url = video_segment.split("data-preview-custom=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let mut title = video_segment.split("\" title=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
// html decode
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
let id = video_url.split("/").collect::<Vec<&str>>()[4].to_string();
|
||||
let raw_duration = video_segment.split("fa fa-clock-o").collect::<Vec<&str>>()[1]
|
||||
.split("<span>").collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let duration = parse_time_to_seconds(&raw_duration).unwrap_or(0) as u32;
|
||||
|
||||
let thumb = video_segment.split("<img class=\"thumb lazy-load\"").collect::<Vec<&str>>()[1]
|
||||
.split("data-original=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
|
||||
let mut tags = vec![];
|
||||
if video_segment.contains("href=\"/sites/"){
|
||||
let raw_tags = video_segment.split("href=\"/sites/").collect::<Vec<&str>>()[1..]
|
||||
.iter()
|
||||
.map(|s| s.split("/\"").collect::<Vec<&str>>()[0].to_string())
|
||||
.collect::<Vec<String>>();
|
||||
for tag in raw_tags {
|
||||
if !tag.is_empty() {
|
||||
tags.push(format!("@sites:{}",tag));
|
||||
}
|
||||
}
|
||||
}
|
||||
if video_segment.contains("href=\"/models/"){
|
||||
let raw_tags = video_segment.split("href=\"/models/").collect::<Vec<&str>>()[1..]
|
||||
.iter()
|
||||
.map(|s| s.split("/\"").collect::<Vec<&str>>()[0].to_string())
|
||||
.collect::<Vec<String>>();
|
||||
for tag in raw_tags {
|
||||
if !tag.is_empty() {
|
||||
tags.push(format!("@models:{}",tag));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let views_part = video_segment.split("fa fa-eye").collect::<Vec<&str>>()[1]
|
||||
.split("<span>").collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let views = parse_abbreviated_number(&views_part).unwrap_or(0) as u32;
|
||||
|
||||
let video_item = VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
video_url.to_string(),
|
||||
"Pornhat".to_string(),
|
||||
thumb,
|
||||
duration,
|
||||
)
|
||||
.preview(preview_url)
|
||||
.views(views)
|
||||
.tags(tags)
|
||||
;
|
||||
items.push(video_item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for PornhatProvider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
pool: DbPool,
|
||||
sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
per_page: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let _ = per_page;
|
||||
let _ = pool;
|
||||
let videos: std::result::Result<Vec<VideoItem>, Error> = match query {
|
||||
Some(q) => {
|
||||
self.query(cache, page.parse::<u8>().unwrap_or(1), &q, options)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
self.get(cache, page.parse::<u8>().unwrap_or(1), &sort, options)
|
||||
.await
|
||||
}
|
||||
};
|
||||
match videos {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
println!("Error fetching videos: {}", e);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
294
src/providers/pornhub.rs
Normal file
@@ -0,0 +1,294 @@
|
||||
use crate::util::parse_abbreviated_number;
|
||||
use crate::DbPool;
|
||||
use crate::providers::Provider;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::time::parse_time_to_seconds;
|
||||
use crate::videos::{ServerOptions, VideoItem};
|
||||
|
||||
use error_chain::error_chain;
|
||||
use htmlentity::entity::{decode, ICodedDataTrait};
|
||||
use async_trait::async_trait;
|
||||
use std::vec;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
}
|
||||
errors {
|
||||
Parse(msg: String) {
|
||||
description("parse error")
|
||||
display("parse error: {}", msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PornhubProvider {
|
||||
url: String,
|
||||
}
|
||||
|
||||
impl PornhubProvider {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
url: "https://www.pornhub.com".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
sort: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let video_url = format!("{}/video?o={}&page={}", self.url, sort, page);
|
||||
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 => {
|
||||
return Ok(items.clone());
|
||||
}
|
||||
Some((_, items)) => items.clone(),
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
let mut requester = match options.requester.clone() {
|
||||
Some(r) => r,
|
||||
None => return Ok(old_items),
|
||||
};
|
||||
|
||||
let text = match requester.get(&video_url, None).await {
|
||||
Ok(t) => t,
|
||||
Err(_) => return Ok(old_items),
|
||||
};
|
||||
|
||||
let video_items = self.get_video_items_from_html(text, "<ul id=\"video");
|
||||
|
||||
if video_items.is_empty() {
|
||||
Ok(old_items)
|
||||
} else {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
Ok(video_items)
|
||||
}
|
||||
}
|
||||
|
||||
async fn query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
query: &str,
|
||||
sort: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let mut split_string = "<ul id=\"video";
|
||||
let search_string = query.to_lowercase().trim().replace(' ', "+");
|
||||
|
||||
let mut video_url =
|
||||
format!("{}/video/search?search={}&page={}", self.url, search_string, page);
|
||||
|
||||
if query.starts_with('@') {
|
||||
let mut parts = query[1..].split(':');
|
||||
let a = parts.next().unwrap_or("");
|
||||
let b = parts.next().unwrap_or("");
|
||||
video_url = format!("{}/{}/{}/videos?page={}", self.url, a, b.replace(' ', "-"), page);
|
||||
|
||||
if query.contains("@model") || query.contains("@pornstar") {
|
||||
split_string = "mostRecentVideosSection";
|
||||
}
|
||||
if query.contains("@channels") {
|
||||
split_string = "<ul class=\"videos row-5-thumbs";
|
||||
}
|
||||
}
|
||||
|
||||
video_url.push_str(match (query.contains("@channels"), sort) {
|
||||
(true, "mv") => "&o=vi",
|
||||
(true, "tr") => "&o=ra",
|
||||
(false, "mv") => "&o=mv",
|
||||
(false, "tr") => "&o=tr",
|
||||
(false, "lg") => "&o=lg",
|
||||
_ => "",
|
||||
});
|
||||
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 => {
|
||||
return Ok(items.clone());
|
||||
}
|
||||
Some((_, items)) => items.clone(),
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
let mut requester = match options.requester.clone() {
|
||||
Some(r) => r,
|
||||
None => return Ok(old_items),
|
||||
};
|
||||
|
||||
let text = match requester.get(&video_url, None).await {
|
||||
Ok(t) => t,
|
||||
Err(_) => return Ok(old_items),
|
||||
};
|
||||
|
||||
let video_items = self.get_video_items_from_html(text, split_string);
|
||||
|
||||
if video_items.is_empty() {
|
||||
Ok(old_items)
|
||||
} else {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
Ok(video_items)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_video_items_from_html(&self, html: String, split_string: &str) -> Vec<VideoItem> {
|
||||
let content = match html.split(split_string).nth(1) {
|
||||
Some(c) => c,
|
||||
None => return vec![],
|
||||
};
|
||||
|
||||
let content = content.split("Porn in German").next().unwrap_or("");
|
||||
|
||||
let mut items = Vec::new();
|
||||
|
||||
for seg in content
|
||||
.split("class=\"pcVideoListItem ")
|
||||
.skip(1)
|
||||
.filter(|s| !s.contains("wrapVideoBlock"))
|
||||
{
|
||||
let url_part = seg
|
||||
.split("<a href=\"")
|
||||
.nth(1)
|
||||
.or_else(|| seg.split("data-video-vkey=\"").nth(1))
|
||||
.and_then(|s| s.split('"').next());
|
||||
|
||||
let video_url = match url_part {
|
||||
Some(u) if !u.is_empty() && u != "javascript:void(0)" => format!("{}{}", self.url, u),
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let mut title = seg
|
||||
.split("\" title=\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
|
||||
let id = match seg
|
||||
.split("data-video-id=\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
{
|
||||
Some(id) => id.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let raw_duration = seg
|
||||
.split("duration")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('>').nth(1))
|
||||
.and_then(|s| s.split('<').next())
|
||||
.unwrap_or("0:00");
|
||||
|
||||
let duration = parse_time_to_seconds(raw_duration).unwrap_or(0) as u32;
|
||||
|
||||
let views = seg
|
||||
.split("iews\">")
|
||||
.filter_map(|p| p.split("<var>").nth(1))
|
||||
.next()
|
||||
.and_then(|v| v.split('<').next())
|
||||
.and_then(|v| parse_abbreviated_number(v))
|
||||
.unwrap_or(0);
|
||||
|
||||
let thumb = seg
|
||||
.split("src=\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let (tag, uploader) = if seg.contains("videoUploaderBlock") {
|
||||
let href = seg
|
||||
.split("videoUploaderBlock")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split("href=\"").nth(1))
|
||||
.and_then(|s| s.split('"').next())
|
||||
.unwrap_or("");
|
||||
|
||||
let parts: Vec<&str> = href.split('/').collect();
|
||||
if parts.len() >= 3 {
|
||||
(
|
||||
Some(format!("@{}:{}", parts[1], parts[2].replace('-', " "))),
|
||||
Some(parts[2].to_string()),
|
||||
)
|
||||
} else {
|
||||
(None, None)
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
let mut item = VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
video_url,
|
||||
"pornhub".into(),
|
||||
thumb,
|
||||
duration,
|
||||
);
|
||||
|
||||
if views > 0 {
|
||||
item = item.views(views);
|
||||
}
|
||||
if let Some(t) = tag {
|
||||
item = item.tags(vec![t]);
|
||||
}
|
||||
if let Some(u) = uploader {
|
||||
item = item.uploader(u);
|
||||
}
|
||||
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for PornhubProvider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
pool: DbPool,
|
||||
sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
per_page: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let _ = pool;
|
||||
let _ = per_page;
|
||||
|
||||
let page = page.parse::<u8>().unwrap_or(1);
|
||||
let mut sort = match sort.as_str() {
|
||||
"mv" => "mv",
|
||||
"tr" => "tr",
|
||||
"cm" => "cm",
|
||||
"lg" => "lg",
|
||||
_ => "mr",
|
||||
};
|
||||
if sort.contains("date") {
|
||||
sort = "mr".into();
|
||||
}
|
||||
|
||||
let res = match query {
|
||||
Some(q) => self.query(cache, page, &q, &sort, options).await,
|
||||
None => self.get(cache, page, &sort, options).await,
|
||||
};
|
||||
|
||||
res.unwrap_or_else(|e| {
|
||||
eprintln!("PornhubProvider error: {e}");
|
||||
vec![]
|
||||
})
|
||||
}
|
||||
}
|
||||
271
src/providers/pornxp.rs
Normal file
@@ -0,0 +1,271 @@
|
||||
use crate::DbPool;
|
||||
use crate::api::ClientVersion;
|
||||
use crate::providers::Provider;
|
||||
use crate::status::*;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::time::parse_time_to_seconds;
|
||||
use crate::videos::{ServerOptions, VideoItem};
|
||||
use async_trait::async_trait;
|
||||
use error_chain::error_chain;
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
// use std::sync::{Arc, RwLock};
|
||||
// use std::thread;
|
||||
use std::vec;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PornxpProvider {
|
||||
url: String,
|
||||
}
|
||||
impl PornxpProvider {
|
||||
pub fn new() -> Self {
|
||||
let provider = PornxpProvider {
|
||||
url: "https://pornxp.me".to_string(),
|
||||
};
|
||||
provider
|
||||
}
|
||||
|
||||
fn build_channel(&self, clientversion: ClientVersion) -> Channel {
|
||||
let _ = clientversion;
|
||||
Channel {
|
||||
id: "pornxp".to_string(),
|
||||
name: "PornXP".to_string(),
|
||||
description: "For Those Who Know The Difference".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=pornxp.me".to_string(),
|
||||
status: "active".to_string(),
|
||||
categories: vec![],
|
||||
options: vec![
|
||||
ChannelOption {
|
||||
id: "sort".to_string(),
|
||||
title: "Sort".to_string(),
|
||||
description: "Sort the Videos".to_string(),
|
||||
systemImage: "list.number".to_string(),
|
||||
colorName: "blue".to_string(),
|
||||
options: vec![
|
||||
FilterOption {
|
||||
id: "new".into(),
|
||||
title: "New".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "best".into(),
|
||||
title: "Best".into(),
|
||||
},
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
nsfw: true,
|
||||
cacheDuration: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
sort: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let sort_string: String = match sort {
|
||||
"best" => "best".to_string(),
|
||||
_ => "new".to_string(),
|
||||
};
|
||||
let video_url = format!(
|
||||
"{}/{}?page={}",
|
||||
self.url, sort_string, page
|
||||
);
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
items.clone()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
let text = requester.get(&video_url, None).await.unwrap();
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
async fn query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
query: &str,
|
||||
sort: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let search_string = query.to_string().to_lowercase().trim().to_string();
|
||||
|
||||
let sort_string: String = match sort {
|
||||
"best" => "".to_string(),
|
||||
_ => "&sort=new".to_string(),
|
||||
};
|
||||
let video_url = format!(
|
||||
"{}/tags/{}?page={}{}",
|
||||
self.url, search_string, page, sort_string
|
||||
);
|
||||
// Check our Video Cache. If the result is younger than 1 hour, we return it.
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
let _ = cache.check().await;
|
||||
return Ok(items.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
|
||||
let text = requester.get(&video_url, None).await.unwrap();
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
fn get_video_items_from_html(&self, html: String) -> Vec<VideoItem> {
|
||||
if html.is_empty() {
|
||||
println!("HTML is empty");
|
||||
return vec![];
|
||||
}
|
||||
let mut items: Vec<VideoItem> = Vec::new();
|
||||
let raw_videos = html.split("id=\"pages\"").collect::<Vec<&str>>()[0]
|
||||
.split("<div id=\"content\"")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("<div class=\"item_cont\">")
|
||||
.collect::<Vec<&str>>()[1..]
|
||||
.to_vec();
|
||||
for video_segment in &raw_videos {
|
||||
// let vid = video_segment.split("\n").collect::<Vec<&str>>();
|
||||
// for (index, line) in vid.iter().enumerate() {
|
||||
// println!("Line {}: {}", index, line);
|
||||
// }
|
||||
let video_url: String = format!("{}{}", self.url, video_segment.split("<a href=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string());
|
||||
let mut title = video_segment
|
||||
.split("<div class=\"item_title\">")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.trim()
|
||||
.to_string();
|
||||
// html decode
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
let id = video_url.split("/").collect::<Vec<&str>>()[4].to_string();
|
||||
|
||||
let thumb = match video_segment.contains("<img class=\"item_img lazy\""){
|
||||
true => format!("https:{}", video_segment.split("<img ").collect::<Vec<&str>>()[1]
|
||||
.split("data-src=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string()),
|
||||
false => format!("https:{}", video_segment.split("<img ").collect::<Vec<&str>>()[1]
|
||||
.split("src=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string()),};
|
||||
let raw_duration = video_segment
|
||||
.split("<div class=\"item_dur\">")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let duration = parse_time_to_seconds(raw_duration.as_str()).unwrap_or(0) as u32;
|
||||
|
||||
let preview = format!("https:{}",video_segment
|
||||
.split("data-preview=\"")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string());
|
||||
let tags = video_segment.split("<div class=\"item_tags\">").collect::<Vec<&str>>()[1]
|
||||
.split("</div>")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.split("<a href=\"")
|
||||
.collect::<Vec<&str>>()[1..]
|
||||
.into_iter().map(|s| s.split(">").collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0].to_string()).collect::<Vec<String>>();
|
||||
let video_item = VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
video_url.to_string(),
|
||||
"pornxpme".to_string(),
|
||||
thumb,
|
||||
duration,
|
||||
)
|
||||
.tags(tags)
|
||||
.preview(preview);
|
||||
items.push(video_item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for PornxpProvider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
pool: DbPool,
|
||||
sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
per_page: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let _ = per_page;
|
||||
let _ = pool;
|
||||
let videos: std::result::Result<Vec<VideoItem>, Error> = match query {
|
||||
Some(q) => {
|
||||
self.query(cache, page.parse::<u8>().unwrap_or(1), &q, &sort, options)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
self.get(cache, page.parse::<u8>().unwrap_or(1), &sort, options)
|
||||
.await
|
||||
}
|
||||
};
|
||||
match videos {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
println!("Error fetching videos: {}", e);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
fn get_channel(&self, clientversion: ClientVersion) -> Option<crate::status::Channel> {
|
||||
Some(self.build_channel(clientversion))
|
||||
}
|
||||
}
|
||||
188
src/providers/pornzog.rs
Normal file
@@ -0,0 +1,188 @@
|
||||
use crate::DbPool;
|
||||
use crate::providers::Provider;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::time::parse_time_to_seconds;
|
||||
use crate::videos::{ServerOptions, VideoItem};
|
||||
use error_chain::error_chain;
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
use std::vec;
|
||||
use async_trait::async_trait;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PornzogProvider {
|
||||
url: String,
|
||||
}
|
||||
impl PornzogProvider {
|
||||
pub fn new() -> Self {
|
||||
PornzogProvider {
|
||||
url: "https://pornzog.com".to_string(),
|
||||
}
|
||||
}
|
||||
async fn query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
query: &str,
|
||||
sort: String,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let mut search_params = vec![format!("page={}", page), "site=hdzog".to_string()];
|
||||
if !query.is_empty() {
|
||||
search_params.push(format!("s={}", query.replace(" ", "+")));
|
||||
}
|
||||
let sort_string = match sort.as_str() {
|
||||
"relevance" => "o=relevance",
|
||||
"viewed" => "o=viewed",
|
||||
"rated" => "o=rated",
|
||||
"longest" => "o=longest",
|
||||
_ => "o=recent",
|
||||
};
|
||||
search_params.push(format!("{}", &sort_string));
|
||||
|
||||
let video_url = format!("{}/search/?{}", self.url, search_params.join("&"));
|
||||
// Check our Video Cache. If the result is younger than 1 hour, we return it.
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
let _ = cache.check().await;
|
||||
return Ok(items.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
let text = requester.get(&video_url, None).await.unwrap();
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
fn get_video_items_from_html(&self, html: String) -> Vec<VideoItem> {
|
||||
if html.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let mut items: Vec<VideoItem> = Vec::new();
|
||||
let raw_videos = html.split("class=\"paginator\"").collect::<Vec<&str>>()[0]
|
||||
.split("class=\"thumb-video ")
|
||||
.collect::<Vec<&str>>()[1..]
|
||||
.to_vec();
|
||||
for video_segment in &raw_videos {
|
||||
// let vid = video_segment.split("\n").collect::<Vec<&str>>();
|
||||
// for (index, line) in vid.iter().enumerate() {
|
||||
// println!("Line {}: {}", index, line);
|
||||
// }
|
||||
let mut video_url: String = video_segment.split("href=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
if video_url.starts_with("/") {
|
||||
video_url = format!("{}{}", self.url, video_url);
|
||||
}
|
||||
let mut title = video_segment.split("alt=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
// html decode
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
let id = video_url.split("/").collect::<Vec<&str>>()[4].to_string();
|
||||
|
||||
let thumb = format!(
|
||||
"{}",
|
||||
video_segment.split("<img ").collect::<Vec<&str>>()[1]
|
||||
.split("data-original=\"")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string()
|
||||
);
|
||||
let raw_duration = video_segment
|
||||
.split("class=\"duration\">")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let duration = parse_time_to_seconds(raw_duration.as_str()).unwrap_or(0) as u32;
|
||||
// let uploader = video_segment.split("class=\"source\">").collect::<Vec<&str>>()[1]
|
||||
// .split(">").collect::<Vec<&str>>()[1]
|
||||
// .split("<").collect::<Vec<&str>>()[0]
|
||||
// .to_string();
|
||||
|
||||
let tags = video_segment.split("class=\"tags\"").collect::<Vec<&str>>()[1]
|
||||
.split("</p>")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.split("<a href=\"")
|
||||
.collect::<Vec<&str>>()[1..]
|
||||
.iter()
|
||||
.map(|el| {
|
||||
el.split(">").collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string()
|
||||
})
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
let video_item = VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
video_url.to_string(),
|
||||
"pornzog".to_string(),
|
||||
thumb,
|
||||
duration,
|
||||
)
|
||||
// .uploader(uploader)
|
||||
.tags(tags);
|
||||
items.push(video_item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for PornzogProvider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
pool: DbPool,
|
||||
sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
per_page: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let _ = per_page;
|
||||
let _ = pool;
|
||||
let videos: std::result::Result<Vec<VideoItem>, Error> = self
|
||||
.query(
|
||||
cache,
|
||||
page.parse::<u8>().unwrap_or(1),
|
||||
query.unwrap_or("".to_string()).as_str(),
|
||||
sort,
|
||||
options,
|
||||
)
|
||||
.await;
|
||||
match videos {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
println!("Error fetching videos: {}", e);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
242
src/providers/redtube.rs
Normal file
@@ -0,0 +1,242 @@
|
||||
use crate::DbPool;
|
||||
use crate::providers::Provider;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::parse_abbreviated_number;
|
||||
use crate::util::time::parse_time_to_seconds;
|
||||
use crate::videos::{ServerOptions, VideoItem};
|
||||
use async_trait::async_trait;
|
||||
use error_chain::error_chain;
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
use serde_json::Value;
|
||||
use std::vec;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RedtubeProvider {
|
||||
url: String,
|
||||
}
|
||||
impl RedtubeProvider {
|
||||
pub fn new() -> Self {
|
||||
RedtubeProvider {
|
||||
url: "https://www.redtube.com".to_string(),
|
||||
}
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
sort: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let _ = sort;
|
||||
let video_url = format!("{}/mostviewed?page={}", self.url, page);
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
items.clone()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
let text = requester.get(&video_url, None).await.unwrap();
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
async fn query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
query: &str,
|
||||
sort: &str,
|
||||
options: ServerOptions
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let _ = sort; //TODO
|
||||
let search_string = query.to_lowercase().trim().replace(" ", "+");
|
||||
let video_url = format!("{}/?search={}&page={}", self.url, search_string, page);
|
||||
|
||||
// Check our Video Cache. If the result is younger than 1 hour, we return it.
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
let _ = cache.check().await;
|
||||
return Ok(items.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
let text = requester.get(&video_url, None).await.unwrap();
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html_query(text.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
fn get_video_items_from_html(&self, html: String) -> Vec<VideoItem> {
|
||||
if html.is_empty() {
|
||||
println!("HTML is empty");
|
||||
return vec![];
|
||||
}
|
||||
let mut items: Vec<VideoItem> = Vec::new();
|
||||
let video_listing_content = html
|
||||
.split("<script type=\"application/ld+json\">")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("</script>")
|
||||
.collect::<Vec<&str>>()[0];
|
||||
let mut videos: Value = serde_json::from_str(video_listing_content).unwrap();
|
||||
for vid in videos.as_array_mut().unwrap() {
|
||||
let video_url: String = vid["embedUrl"].as_str().unwrap_or("").to_string();
|
||||
let mut title: String = vid["name"].as_str().unwrap_or("").to_string();
|
||||
// html decode
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
let id = video_url.split("=").collect::<Vec<&str>>()[1].to_string();
|
||||
let raw_duration = vid["duration"].as_str().unwrap_or("0");
|
||||
let duration = raw_duration
|
||||
.replace("PT", "")
|
||||
.replace("S", "")
|
||||
.parse::<u32>()
|
||||
.unwrap();
|
||||
let views: u64 = vid["interactionCount"].as_u64().unwrap_or(0);
|
||||
let thumb = vid["thumbnailUrl"].as_str().unwrap_or("").to_string();
|
||||
|
||||
let video_item = VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
video_url.to_string(),
|
||||
"redtube".to_string(),
|
||||
thumb,
|
||||
duration,
|
||||
)
|
||||
.views(views as u32);
|
||||
items.push(video_item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
fn get_video_items_from_html_query(&self, html: String) -> Vec<VideoItem> {
|
||||
if html.is_empty() {
|
||||
println!("HTML is empty");
|
||||
return vec![];
|
||||
}
|
||||
let mut items: Vec<VideoItem> = Vec::new();
|
||||
let video_listing_content = html.split("videos_grid").collect::<Vec<&str>>()[1];
|
||||
let videos = video_listing_content
|
||||
.split("<li id=\"tags_videos_")
|
||||
.collect::<Vec<&str>>()[1..]
|
||||
.to_vec();
|
||||
for vid in videos {
|
||||
// for (i, c) in vid.split("\n").enumerate() {
|
||||
// println!("{}: {}", i, c);
|
||||
// }
|
||||
let id = vid.split("data-video-id=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let video_url = format!("{}/{}", self.url, id);
|
||||
let title = vid.split(" <a title=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.trim()
|
||||
.to_string();
|
||||
let thumb = vid.split("<img").collect::<Vec<&str>>()[1]
|
||||
.split(" data-src=\"")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let raw_duration = vid
|
||||
.split("<span class=\"video-properties tm_video_duration\">")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("</span>")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.trim()
|
||||
.to_string();
|
||||
let duration = parse_time_to_seconds(&raw_duration).unwrap_or(0) as u32;
|
||||
let views_str = vid
|
||||
.split("<span class='info-views'>")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("</span>")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.trim()
|
||||
.to_string();
|
||||
let views = parse_abbreviated_number(&views_str).unwrap_or(0) as u32;
|
||||
let preview = vid.split("<img").collect::<Vec<&str>>()[1]
|
||||
.split(" data-mediabook=\"")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
|
||||
let video_item =
|
||||
VideoItem::new(id, title, video_url, "redtube".to_string(), thumb, duration)
|
||||
.views(views)
|
||||
.preview(preview);
|
||||
items.push(video_item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for RedtubeProvider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
pool: DbPool,
|
||||
sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
per_page: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let _ = options;
|
||||
let _ = per_page;
|
||||
let _ = pool;
|
||||
let mut sort = sort.to_lowercase();
|
||||
if sort.contains("date") {
|
||||
sort = "mr".to_string();
|
||||
}
|
||||
let videos: std::result::Result<Vec<VideoItem>, Error> = match query {
|
||||
Some(q) => {
|
||||
self.query(cache, page.parse::<u8>().unwrap_or(1), &q, &sort, options)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
self.get(cache, page.parse::<u8>().unwrap_or(1), &sort, options)
|
||||
.await
|
||||
}
|
||||
};
|
||||
match videos {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
println!("Error fetching videos: {}", e);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
288
src/providers/rule34gen.rs
Normal file
@@ -0,0 +1,288 @@
|
||||
use crate::api::*;
|
||||
use crate::status::*;
|
||||
use crate::util::parse_abbreviated_number;
|
||||
use crate::DbPool;
|
||||
use crate::providers::Provider;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::time::parse_time_to_seconds;
|
||||
use crate::videos::{ServerOptions, VideoItem};
|
||||
use error_chain::error_chain;
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
use std::vec;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use async_trait::async_trait;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Rule34genProvider {
|
||||
url: String,
|
||||
}
|
||||
impl Rule34genProvider {
|
||||
pub fn new() -> Self {
|
||||
Rule34genProvider {
|
||||
url: "https://rule34gen.com".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_channel(&self, clientversion: ClientVersion) -> Channel {
|
||||
let _ = clientversion;
|
||||
Channel {
|
||||
id: "rule34gen".to_string(),
|
||||
name: "Rule34Gen".to_string(),
|
||||
description: "If it exists, here might be an AI generated video of it".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=rule34gen.com".to_string(),
|
||||
status: "active".to_string(),
|
||||
categories: vec![],
|
||||
options: vec![ChannelOption {
|
||||
id: "sort".to_string(),
|
||||
title: "Sort".to_string(),
|
||||
description: "Sort the Videos".to_string(), //"Sort the videos by Date or Name.".to_string(),
|
||||
systemImage: "list.number".to_string(),
|
||||
colorName: "blue".to_string(),
|
||||
options: vec![
|
||||
FilterOption {
|
||||
id: "post_date".to_string(),
|
||||
title: "Newest".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "video_viewed".to_string(),
|
||||
title: "Most Viewed".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "rating".to_string(),
|
||||
title: "Top Rated".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "duration".to_string(),
|
||||
title: "Longest".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "pseudo_random".to_string(),
|
||||
title: "Random".to_string(),
|
||||
},
|
||||
],
|
||||
multiSelect: false,
|
||||
}],
|
||||
nsfw: true,
|
||||
cacheDuration: Some(1800),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
sort: &str,
|
||||
options: ServerOptions
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("Time went backwards");
|
||||
|
||||
let timestamp_millis = now.as_millis(); // u128
|
||||
let expected_sorts = vec!["post_date", "video_viewed", "rating", "duration", "pseudo_random"];
|
||||
let sort = if expected_sorts.contains(&sort) {
|
||||
sort
|
||||
} else {
|
||||
"post_date"
|
||||
};
|
||||
|
||||
let index = format!("rule34gen:{}:{}", page, sort);
|
||||
|
||||
let url = format!("{}/?mode=async&function=get_block&block_id=custom_list_videos_most_recent_videos&tag_ids=&sort_by={}&from={}&_={}", self.url, sort, page, timestamp_millis);
|
||||
|
||||
let mut old_items: Vec<VideoItem> = vec![];
|
||||
if !(sort == "pseudo_random") {
|
||||
old_items = match cache.get(&index) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
// println!("Cache hit for URL: {}", url);
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
items.clone()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
}
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
let text = requester.get(&url, None).await.unwrap();
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&url);
|
||||
cache.insert(url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
async fn query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
query: &str,
|
||||
sort: &str,
|
||||
options: ServerOptions
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("Time went backwards");
|
||||
let timestamp_millis = now.as_millis(); // u128
|
||||
let expected_sorts = vec!["post_date", "video_viewed", "rating", "duration", "pseudo_random"];
|
||||
let sort = if expected_sorts.contains(&sort) {
|
||||
sort
|
||||
} else {
|
||||
"post_date"
|
||||
};
|
||||
|
||||
let index = format!("rule34gen:{}:{}:{}", page, sort, query);
|
||||
|
||||
let url = format!("{}/search/{}/?mode=async&function=get_block&block_id=custom_list_videos_videos_list_search&tag_ids=&sort_by={}&from_videos={}&from_albums={}&_={}", self.url, query.replace(" ","-"), sort, page, page, timestamp_millis);
|
||||
|
||||
// Check our Video Cache. If the result is younger than 1 hour, we return it.
|
||||
let old_items = match cache.get(&index) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
let _ = cache.check().await;
|
||||
return Ok(items.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
let text = requester.get(&url, None).await.unwrap();
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&url);
|
||||
cache.insert(url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
fn get_video_items_from_html(&self, html: String) -> Vec<VideoItem> {
|
||||
if html.is_empty() {
|
||||
println!("HTML is empty");
|
||||
return vec![];
|
||||
}
|
||||
let mut items: Vec<VideoItem> = Vec::new();
|
||||
let video_listing_content = html.split("<div class=\"thumbs clearfix\" id=\"custom_list_videos").collect::<Vec<&str>>()[1].split("<div class=\"pagination\"").collect::<Vec<&str>>()[0].to_string();
|
||||
let raw_videos = video_listing_content
|
||||
.split("<div class=\"item thumb video_")
|
||||
.collect::<Vec<&str>>()[1..]
|
||||
.to_vec();
|
||||
for video_segment in &raw_videos {
|
||||
// let vid = video_segment.split("\n").collect::<Vec<&str>>()[1]
|
||||
// for (index, line) in vid.iter().enumerate() {
|
||||
// println!("Line {}: {}", index, line);
|
||||
// }
|
||||
|
||||
if video_segment.contains("https://rule34gen.com/images/advertisements"){
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut title = video_segment.split("<div class=\"thumb_title\">").collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
// html decode
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
let id = video_segment.split("https://rule34gen.com/video/").collect::<Vec<&str>>()[1].split("/").collect::<Vec<&str>>()[0].to_string();
|
||||
let raw_duration = video_segment.split("<div class=\"time\">").collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let duration = parse_time_to_seconds(&raw_duration).unwrap_or(0) as u32;
|
||||
let views = parse_abbreviated_number(&video_segment
|
||||
.split("<div class=\"views\">").collect::<Vec<&str>>()[1].split("</svg>").collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]).unwrap_or(0);
|
||||
//https://rule34gen.com/get_file/47/5e71602b7642f9b997f90c979a368c99b8aad90d89/3942000/3942353/3942353_preview.mp4/
|
||||
//https://rule34gen.com/get_file/47/5e71602b7642f9b997f90c979a368c99b8aad90d89/3942000/3942353/3942353_preview.mp4/
|
||||
let thumb = video_segment.split("<img class=\"thumb lazy-load\" src=\"").collect::<Vec<&str>>()[1].split("data-original=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let url = video_segment.split("<a class=\"th js-open-popup\" href=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
// let preview = video_segment.split("<div class=\"img wrap_image\" data-preview=\"").collect::<Vec<&str>>()[1]
|
||||
// .split("\"")
|
||||
// .collect::<Vec<&str>>()[0]
|
||||
// .to_string();
|
||||
|
||||
|
||||
let video_item = VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
url.to_string(),
|
||||
"rule34gen".to_string(),
|
||||
thumb,
|
||||
duration,
|
||||
)
|
||||
.views(views)
|
||||
// .preview(preview)
|
||||
;
|
||||
|
||||
|
||||
items.push(video_item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for Rule34genProvider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
pool: DbPool,
|
||||
sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
per_page: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let _ = options;
|
||||
let _ = per_page;
|
||||
let _ = pool; // Ignored in this implementation
|
||||
let videos: std::result::Result<Vec<VideoItem>, Error> = match query {
|
||||
Some(q) => {
|
||||
self.query(cache, page.parse::<u8>().unwrap_or(1), &q, &sort, options)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
self.get(cache, page.parse::<u8>().unwrap_or(1), &sort, options)
|
||||
.await
|
||||
}
|
||||
};
|
||||
match videos {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
println!("Error fetching videos: {}", e);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_channel(&self, clientversion: ClientVersion) -> Option<crate::status::Channel> {
|
||||
Some(self.build_channel(clientversion))
|
||||
}
|
||||
}
|
||||
268
src/providers/rule34video.rs
Normal file
@@ -0,0 +1,268 @@
|
||||
use crate::DbPool;
|
||||
use crate::providers::Provider;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::discord::send_discord_error_report;
|
||||
use crate::util::parse_abbreviated_number;
|
||||
use crate::util::time::parse_time_to_seconds;
|
||||
use crate::videos::{ServerOptions, VideoItem};
|
||||
use async_trait::async_trait;
|
||||
use error_chain::error_chain;
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::vec;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
}
|
||||
errors {
|
||||
ParsingError(t: String) {
|
||||
description("html parsing error")
|
||||
display("HTML parsing error: '{}'", t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Rule34videoProvider {
|
||||
url: String,
|
||||
}
|
||||
|
||||
impl Rule34videoProvider {
|
||||
pub fn new() -> Self {
|
||||
Rule34videoProvider {
|
||||
url: "https://rule34video.com".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to safely extract a string between two delimiters
|
||||
fn extract_between<'a>(content: &'a str, start_pat: &str, end_pat: &str) -> Option<&'a str> {
|
||||
let start_idx = content.find(start_pat)? + start_pat.len();
|
||||
let sub = &content[start_idx..];
|
||||
let end_idx = sub.find(end_pat)?;
|
||||
Some(&sub[..end_idx])
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
sort: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let timestamp_millis = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0);
|
||||
|
||||
let expected_sorts = vec![
|
||||
"post_date",
|
||||
"video_viewed",
|
||||
"rating",
|
||||
"duration",
|
||||
"pseudo_random",
|
||||
];
|
||||
let sort_val = if expected_sorts.contains(&sort) {
|
||||
sort
|
||||
} else {
|
||||
"post_date"
|
||||
};
|
||||
|
||||
let index = format!("rule34video:{}:{}", page, sort_val);
|
||||
|
||||
if sort_val != "pseudo_random" {
|
||||
if let Some((time, items)) = cache.get(&index) {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 300 {
|
||||
return Ok(items.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut requester = options.requester.clone().ok_or("Requester missing")?;
|
||||
let url = format!(
|
||||
"{}/?mode=async&function=get_block&block_id=custom_list_videos_most_recent_videos&tag_ids=&sort_by={}&from={}&_={}",
|
||||
self.url, sort_val, page, timestamp_millis
|
||||
);
|
||||
|
||||
let text = requester.get(&url, None).await.unwrap_or_else(|e| {
|
||||
eprintln!("Error fetching rule34video URL {}: {}", url, e);
|
||||
let _ = send_discord_error_report(e.to_string(), None, Some(&url), None, file!(), line!(), module_path!());
|
||||
"".to_string()
|
||||
});
|
||||
let video_items = self.get_video_items_from_html(text);
|
||||
|
||||
if !video_items.is_empty() {
|
||||
cache.insert(index, video_items.clone());
|
||||
Ok(video_items)
|
||||
} else {
|
||||
// Return empty or old items if available
|
||||
Ok(cache
|
||||
.get(&index)
|
||||
.map(|(_, items)| items)
|
||||
.unwrap_or_default())
|
||||
}
|
||||
}
|
||||
|
||||
async fn query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
query: &str,
|
||||
sort: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let timestamp_millis = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0);
|
||||
|
||||
let expected_sorts = vec![
|
||||
"post_date",
|
||||
"video_viewed",
|
||||
"rating",
|
||||
"duration",
|
||||
"pseudo_random",
|
||||
];
|
||||
let sort_val = if expected_sorts.contains(&sort) {
|
||||
sort
|
||||
} else {
|
||||
"post_date"
|
||||
};
|
||||
|
||||
let index = format!("rule34video:{}:{}:{}", page, sort_val, query);
|
||||
|
||||
if let Some((time, items)) = cache.get(&index) {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 300 {
|
||||
return Ok(items.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let mut requester = options.requester.clone().ok_or("Requester missing")?;
|
||||
let url = format!(
|
||||
"{}/search/{}/?mode=async&function=get_block&block_id=custom_list_videos_videos_list_search&tag_ids=&sort_by={}&from_videos={}&from_albums={}&_={}",
|
||||
self.url,
|
||||
query.replace(" ", "-"),
|
||||
sort_val,
|
||||
page,
|
||||
page,
|
||||
timestamp_millis
|
||||
);
|
||||
|
||||
let text = requester.get(&url, None).await.unwrap_or_else(|e| {
|
||||
eprintln!("Error fetching rule34video URL {}: {}", url, e);
|
||||
let _ = send_discord_error_report(e.to_string(), None, Some(&url), None, file!(), line!(), module_path!());
|
||||
"".to_string()
|
||||
});
|
||||
let video_items = self.get_video_items_from_html(text);
|
||||
|
||||
if !video_items.is_empty() {
|
||||
cache.insert(index, video_items.clone());
|
||||
Ok(video_items)
|
||||
} else {
|
||||
Ok(cache
|
||||
.get(&index)
|
||||
.map(|(_, items)| items)
|
||||
.unwrap_or_default())
|
||||
}
|
||||
}
|
||||
|
||||
fn get_video_items_from_html(&self, html: String) -> Vec<VideoItem> {
|
||||
if html.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// Safely isolate the video listing section
|
||||
let video_listing = match Self::extract_between(
|
||||
&html,
|
||||
"id=\"custom_list_videos",
|
||||
"<div class=\"pagination\"",
|
||||
) {
|
||||
Some(content) => content,
|
||||
None => return vec![],
|
||||
};
|
||||
|
||||
let mut items = Vec::new();
|
||||
// Skip the first split result as it's the preamble
|
||||
let raw_videos = video_listing
|
||||
.split("<div class=\"item thumb video_")
|
||||
.skip(1);
|
||||
|
||||
for video_segment in raw_videos {
|
||||
if video_segment.contains("title=\"Advertisement\"") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Title extraction
|
||||
let title_raw =
|
||||
Self::extract_between(video_segment, "<div class=\"thumb_title\">", "<")
|
||||
.unwrap_or("Unknown");
|
||||
let title = decode(title_raw.as_bytes())
|
||||
.to_string()
|
||||
.unwrap_or_else(|_| title_raw.to_string());
|
||||
|
||||
// ID extraction
|
||||
let id = Self::extract_between(video_segment, "https://rule34video.com/video/", "/")
|
||||
.unwrap_or("0")
|
||||
.to_string();
|
||||
|
||||
// Duration extraction
|
||||
let raw_duration =
|
||||
Self::extract_between(video_segment, "<div class=\"time\">", "<").unwrap_or("0:00");
|
||||
let duration = parse_time_to_seconds(raw_duration).unwrap_or(0) as u32;
|
||||
|
||||
// Views extraction
|
||||
let views_segment = Self::extract_between(video_segment, "<div class=\"views\">", "<");
|
||||
let views_count_str = views_segment
|
||||
.and_then(|s| s.split("</svg>").nth(1))
|
||||
.unwrap_or("0");
|
||||
let views = parse_abbreviated_number(views_count_str.trim()).unwrap_or(0);
|
||||
|
||||
// Thumbnail extraction
|
||||
let thumb = Self::extract_between(video_segment, "data-original=\"", "\"")
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// URL extraction
|
||||
let url =
|
||||
Self::extract_between(video_segment, "<a class=\"th js-open-popup\" href=\"", "\"")
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
items.push(
|
||||
VideoItem::new(id, title, url, "Rule34video".to_string(), thumb, duration)
|
||||
.views(views),
|
||||
);
|
||||
}
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for Rule34videoProvider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
_pool: DbPool,
|
||||
sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
_per_page: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let page_num = page.parse::<u8>().unwrap_or(1);
|
||||
|
||||
let result = match query {
|
||||
Some(q) => self.query(cache, page_num, &q, &sort, options).await,
|
||||
None => self.get(cache, page_num, &sort, options).await,
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("Error fetching videos: {}", e);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
394
src/providers/sxyprn.rs
Normal file
@@ -0,0 +1,394 @@
|
||||
use crate::DbPool;
|
||||
use crate::providers::Provider;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::discord::format_error_chain;
|
||||
use crate::util::discord::send_discord_error_report;
|
||||
use crate::util::requester::Requester;
|
||||
use crate::util::time::parse_time_to_seconds;
|
||||
use crate::videos::ServerOptions;
|
||||
use crate::videos::VideoItem;
|
||||
use async_trait::async_trait;
|
||||
use error_chain::error_chain;
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
use scraper::{Html, Selector};
|
||||
use std::vec;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
JsonError(serde_json::Error);
|
||||
}
|
||||
errors {
|
||||
Parse(msg: String) {
|
||||
description("html parse error")
|
||||
display("html parse error: {}", msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SxyprnProvider {
|
||||
url: String,
|
||||
}
|
||||
impl SxyprnProvider {
|
||||
pub fn new() -> Self {
|
||||
SxyprnProvider {
|
||||
url: "https://sxyprn.com".to_string(),
|
||||
}
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
pool: DbPool,
|
||||
page: u8,
|
||||
sort: String,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let sort_string = match sort.as_str() {
|
||||
"views" => "views",
|
||||
"rating" => "rating",
|
||||
"orgasmic" => "orgasmic",
|
||||
_ => "latest",
|
||||
};
|
||||
// Extract needed fields from options at the start
|
||||
let filter = options.filter.clone().unwrap();
|
||||
let filter_string = match filter.as_str() {
|
||||
"other" => "other",
|
||||
"all" => "all",
|
||||
_ => "top",
|
||||
};
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
|
||||
let url_str = format!(
|
||||
"{}/blog/all/{}.html?fl={}&sm={}",
|
||||
self.url,
|
||||
((page as u32) - 1) * 20,
|
||||
filter_string,
|
||||
sort_string
|
||||
);
|
||||
|
||||
let old_items = match cache.get(&url_str) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 60 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
items.clone()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let text = requester.get(&url_str, None).await.unwrap();
|
||||
// Pass a reference to options if needed, or reconstruct as needed
|
||||
let video_items = match self
|
||||
.get_video_items_from_html(text.clone(), pool, requester)
|
||||
.await
|
||||
{
|
||||
Ok(items) => items,
|
||||
Err(e) => {
|
||||
println!("Error parsing video items: {}", e);
|
||||
send_discord_error_report(
|
||||
e.to_string(),
|
||||
Some(format_error_chain(&e)),
|
||||
Some("Sxyprn Provider"),
|
||||
Some(&format!("URL: {}", url_str)),
|
||||
file!(),
|
||||
line!(),
|
||||
module_path!(),
|
||||
).await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
// let video_items: Vec<VideoItem> = self
|
||||
// .get_video_items_from_html(text.clone(), pool, requester)
|
||||
// .await;
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&url_str);
|
||||
cache.insert(url_str.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
async fn query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
pool: DbPool,
|
||||
page: u8,
|
||||
query: &str,
|
||||
sort: String,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let sort_string = match sort.as_str() {
|
||||
"views" => "views",
|
||||
"rating" => "trending",
|
||||
"orgasmic" => "orgasmic",
|
||||
_ => "latest",
|
||||
};
|
||||
// Extract needed fields from options at the start
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
let search_string = query.replace(" ", "-");
|
||||
let url_str = format!(
|
||||
"{}/{}.html?page={}&sm={}",
|
||||
self.url,
|
||||
search_string,
|
||||
((page as u32) - 1) * 20,
|
||||
sort_string
|
||||
);
|
||||
// Check our Video Cache. If the result is younger than 1 hour, we return it.
|
||||
let old_items = match cache.get(&url_str) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 60 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
let _ = cache.check().await;
|
||||
return Ok(items.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
let text = requester.get(&url_str, None).await.unwrap();
|
||||
|
||||
let video_items = match self
|
||||
.get_video_items_from_html(text.clone(), pool, requester)
|
||||
.await
|
||||
{
|
||||
Ok(items) => items,
|
||||
Err(e) => {
|
||||
println!("Error parsing video items: {}", e);// 1. Convert the error to a string immediately
|
||||
send_discord_error_report(
|
||||
e.to_string(),
|
||||
Some(format_error_chain(&e)),
|
||||
Some("Sxyprn Provider"),
|
||||
Some(&format!("URL: {}", url_str)),
|
||||
file!(),
|
||||
line!(),
|
||||
module_path!(),
|
||||
).await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
|
||||
// let video_items: Vec<VideoItem> = self
|
||||
// .get_video_items_from_html(text.clone(), pool, requester)
|
||||
// .await;
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&url_str);
|
||||
cache.insert(url_str.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
async fn get_video_items_from_html(
|
||||
&self,
|
||||
html: String,
|
||||
_pool: DbPool,
|
||||
_requester: Requester,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
if html.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
// take content before "<script async"
|
||||
let before_script = html
|
||||
.split("<script async")
|
||||
.next()
|
||||
.ok_or_else(|| ErrorKind::Parse("missing '<script async' split point".into()))?;
|
||||
|
||||
// split into video segments (skip the first chunk)
|
||||
let raw_videos: Vec<&str> = before_script.split("post_el_small'").skip(1).collect();
|
||||
|
||||
if raw_videos.is_empty() {
|
||||
return Err(ErrorKind::Parse("no 'post_el_small\\'' segments found".into()).into());
|
||||
}
|
||||
|
||||
let mut items = Vec::new();
|
||||
|
||||
for video_segment in raw_videos {
|
||||
// url id
|
||||
let url = video_segment
|
||||
.split("/post/")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('\'').next())
|
||||
.ok_or_else(|| ErrorKind::Parse("failed to extract /post/ url".into()))?
|
||||
.to_string();
|
||||
|
||||
let video_url = format!("https://hottub.spacemoehre.de/proxy/sxyprn/post/{}", url);
|
||||
|
||||
// title parts
|
||||
let title_parts = video_segment
|
||||
.split("post_text")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split("style=''>").nth(1))
|
||||
.and_then(|s| s.split("</div>").next())
|
||||
.ok_or_else(|| ErrorKind::Parse("failed to extract title_parts".into()))?;
|
||||
|
||||
let document = Html::parse_document(title_parts);
|
||||
let selector = Selector::parse("*")
|
||||
.map_err(|e| ErrorKind::Parse(format!("selector parse failed: {e}")))?;
|
||||
|
||||
let mut texts = Vec::new();
|
||||
for element in document.select(&selector) {
|
||||
let text = element.text().collect::<Vec<_>>().join(" ");
|
||||
if !text.trim().is_empty() {
|
||||
texts.push(text.trim().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let mut title = texts.join(" ");
|
||||
title = decode(title.as_bytes())
|
||||
.to_string()
|
||||
.unwrap_or(title)
|
||||
.replace(" ", " ");
|
||||
|
||||
title = title
|
||||
.replace('\n', "")
|
||||
.replace(" + ", " ")
|
||||
.replace(" ", " ")
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if title.to_ascii_lowercase().starts_with("new ") {
|
||||
title = title[4..].to_string();
|
||||
}
|
||||
|
||||
// id (DON'T index [6])
|
||||
let id = video_url
|
||||
.split('/')
|
||||
.last()
|
||||
.ok_or_else(|| ErrorKind::Parse("failed to extract id from video_url".into()))?
|
||||
.split('?')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// thumb
|
||||
let thumb_path = video_segment
|
||||
.split("<img class='mini_post_vid_thumb lazyload'")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split("data-src='").nth(1))
|
||||
.and_then(|s| s.split('\'').next())
|
||||
.ok_or_else(|| ErrorKind::Parse("failed to extract thumb".into()))?;
|
||||
|
||||
let thumb = format!("https:{thumb_path}");
|
||||
|
||||
// preview
|
||||
let preview = if video_segment.contains("class='hvp_player'") {
|
||||
Some(format!(
|
||||
"https:{}",
|
||||
video_segment
|
||||
.split("class='hvp_player'")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split(" src='").nth(1))
|
||||
.and_then(|s| s.split('\'').next())
|
||||
.ok_or_else(|| ErrorKind::Parse("failed to extract preview src".into()))?
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// views
|
||||
let views = video_segment
|
||||
.split("<strong>·</strong> ")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split_whitespace().next())
|
||||
.ok_or_else(|| ErrorKind::Parse("failed to extract views".into()))?
|
||||
.to_string();
|
||||
|
||||
// duration
|
||||
let raw_duration = video_segment
|
||||
.split("duration_small")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split("title='").nth(1))
|
||||
.and_then(|s| s.split('\'').nth(1))
|
||||
.and_then(|s| s.split('>').nth(1))
|
||||
.and_then(|s| s.split('<').next())
|
||||
.ok_or_else(|| ErrorKind::Parse("failed to extract duration".into()))?
|
||||
.to_string();
|
||||
|
||||
let duration = parse_time_to_seconds(&raw_duration).unwrap_or(0) as u32;
|
||||
|
||||
// stream urls (your filter condition looks suspicious; leaving as-is)
|
||||
let stream_urls = video_segment
|
||||
.split("extlink_icon extlink")
|
||||
.filter_map(|part| {
|
||||
part.split("href='")
|
||||
.last()
|
||||
.and_then(|s| s.split('\'').next())
|
||||
.map(|u| u.to_string())
|
||||
})
|
||||
.filter(|url| url.starts_with("https://lulustream."))
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
let video_item_url = stream_urls.first().cloned().unwrap_or_else(|| {
|
||||
format!("https://hottub.spacemoehre.de/proxy/sxyprn/post/{}", id)
|
||||
});
|
||||
|
||||
let mut video_item = VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
video_item_url,
|
||||
"sxyprn".to_string(),
|
||||
thumb,
|
||||
duration,
|
||||
)
|
||||
.views(views.parse::<u32>().unwrap_or(0));
|
||||
|
||||
if let Some(p) = preview {
|
||||
video_item = video_item.preview(p);
|
||||
}
|
||||
|
||||
items.push(video_item);
|
||||
}
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for SxyprnProvider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
pool: DbPool,
|
||||
sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
per_page: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let _ = per_page;
|
||||
let videos: std::result::Result<Vec<VideoItem>, Error> = match query {
|
||||
Some(q) => {
|
||||
self.query(
|
||||
cache,
|
||||
pool,
|
||||
page.parse::<u8>().unwrap_or(1),
|
||||
&q,
|
||||
sort,
|
||||
options,
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
self.get(cache, pool, page.parse::<u8>().unwrap_or(1), sort, options)
|
||||
.await
|
||||
}
|
||||
};
|
||||
match videos {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
println!("Error fetching videos: {}", e);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
560
src/providers/tnaflix.rs
Normal file
@@ -0,0 +1,560 @@
|
||||
use crate::DbPool;
|
||||
use crate::api::ClientVersion;
|
||||
use crate::providers::Provider;
|
||||
use crate::status::*;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::parse_abbreviated_number;
|
||||
use crate::util::time::parse_time_to_seconds;
|
||||
use crate::videos::{ServerOptions, VideoItem};
|
||||
use async_trait::async_trait;
|
||||
use error_chain::error_chain;
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
// use std::sync::{Arc, RwLock};
|
||||
// use std::thread;
|
||||
use std::vec;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TnaflixProvider {
|
||||
url: String,
|
||||
// sites: Arc<RwLock<Vec<FilterOption>>>,
|
||||
// categories: Arc<RwLock<Vec<FilterOption>>>,
|
||||
// stars: Arc<RwLock<Vec<FilterOption>>>,
|
||||
}
|
||||
impl TnaflixProvider {
|
||||
pub fn new() -> Self {
|
||||
let provider = TnaflixProvider {
|
||||
url: "https://www.tnaflix.com".to_string(),
|
||||
// sites: Arc::new(RwLock::new(vec![FilterOption {
|
||||
// id: "all".to_string(),
|
||||
// title: "All".to_string(),
|
||||
// }])),
|
||||
// categories: Arc::new(RwLock::new(vec![FilterOption {
|
||||
// id: "all".to_string(),
|
||||
// title: "All".to_string(),
|
||||
// }])),
|
||||
// stars: Arc::new(RwLock::new(vec![FilterOption {
|
||||
// id: "all".to_string(),
|
||||
// title: "All".to_string(),
|
||||
// }])),
|
||||
};
|
||||
|
||||
// Kick off the background load but return immediately
|
||||
// provider.spawn_initial_load();
|
||||
provider
|
||||
}
|
||||
|
||||
// fn spawn_initial_load(&self) {
|
||||
// let url = self.url.clone();
|
||||
// let sites = Arc::clone(&self.sites);
|
||||
// let categories = Arc::clone(&self.categories);
|
||||
// let stars = Arc::clone(&self.stars);
|
||||
|
||||
// thread::spawn(move || {
|
||||
// // Create a tiny runtime just for these async tasks
|
||||
// let rt = tokio::runtime::Builder::new_current_thread()
|
||||
// .enable_all()
|
||||
// .build()
|
||||
// .expect("build tokio runtime");
|
||||
|
||||
// rt.block_on(async move {
|
||||
// // If you have a streaming sites loader, call it here too
|
||||
// if let Err(e) = Self::load_sites(&url, sites).await {
|
||||
// eprintln!("load_sites_into failed: {e}");
|
||||
// }
|
||||
|
||||
// if let Err(e) = Self::load_categories(&url, categories).await {
|
||||
// eprintln!("load_categories failed: {e}");
|
||||
// }
|
||||
|
||||
// if let Err(e) = Self::load_stars(&url, stars).await {
|
||||
// eprintln!("load_stars failed: {e}");
|
||||
// }
|
||||
// });
|
||||
// });
|
||||
// }
|
||||
|
||||
// async fn load_stars(base_url: &str, stars: Arc<RwLock<Vec<FilterOption>>>) -> Result<()> {
|
||||
// let mut requester = util::requester::Requester::new();
|
||||
// for page in [1..10].into_iter().flatten() {
|
||||
// let text = requester
|
||||
// .get(format!("{}/pornstars?page={}", &base_url, page).as_str())
|
||||
// .await
|
||||
// .unwrap();
|
||||
// if text.contains("404 Not Found") || text.is_empty() {
|
||||
// break;
|
||||
// }
|
||||
// let stars_div = text
|
||||
// .split("<span>Hall of Fame Pornstars</span>")
|
||||
// .collect::<Vec<&str>>()[1]
|
||||
// .split("pagination")
|
||||
// .collect::<Vec<&str>>()[0];
|
||||
// for stars_element in stars_div.split("<a ").collect::<Vec<&str>>()[1..].to_vec() {
|
||||
// let star_url = stars_element.split("href=\"").collect::<Vec<&str>>()[1]
|
||||
// .split("\"")
|
||||
// .collect::<Vec<&str>>()[0];
|
||||
// let star_id = star_url.split("/").collect::<Vec<&str>>()[4].to_string();
|
||||
// let star_name = stars_element.split("title=\"").collect::<Vec<&str>>()[1]
|
||||
// .split("\"")
|
||||
// .collect::<Vec<&str>>()[0]
|
||||
// .to_string();
|
||||
// Self::push_unique(
|
||||
// &stars,
|
||||
// FilterOption {
|
||||
// id: star_id,
|
||||
// title: star_name,
|
||||
// },
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
// return Ok(());
|
||||
// }
|
||||
|
||||
// async fn load_sites(base_url: &str, sites: Arc<RwLock<Vec<FilterOption>>>) -> Result<()> {
|
||||
// let mut requester = util::requester::Requester::new();
|
||||
// let mut page = 0;
|
||||
// loop {
|
||||
// page += 1;
|
||||
// let text = requester
|
||||
// .get(format!("{}/sites/{}/", &base_url, page).as_str())
|
||||
// .await
|
||||
// .unwrap();
|
||||
// if text.contains("404 Not Found") || text.is_empty() {
|
||||
// break;
|
||||
// }
|
||||
// let sites_div = text
|
||||
// .split("id=\"list_content_sources_sponsors_list_items\"")
|
||||
// .collect::<Vec<&str>>()[1]
|
||||
// .split("class=\"pagination\"")
|
||||
// .collect::<Vec<&str>>()[0];
|
||||
// for sites_element in
|
||||
// sites_div.split("class=\"headline\"").collect::<Vec<&str>>()[1..].to_vec()
|
||||
// {
|
||||
// let site_url = sites_element.split("href=\"").collect::<Vec<&str>>()[1]
|
||||
// .split("\"")
|
||||
// .collect::<Vec<&str>>()[0];
|
||||
// let site_id = site_url.split("/").collect::<Vec<&str>>()[4].to_string();
|
||||
// let site_name = sites_element.split("<h2>").collect::<Vec<&str>>()[1]
|
||||
// .split("<")
|
||||
// .collect::<Vec<&str>>()[0]
|
||||
// .to_string();
|
||||
// Self::push_unique(
|
||||
// &sites,
|
||||
// FilterOption {
|
||||
// id: site_id,
|
||||
// title: site_name,
|
||||
// },
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
// return Ok(());
|
||||
// }
|
||||
|
||||
// async fn load_networks(base_url: &str, networks: Arc<RwLock<Vec<FilterOption>>>) -> Result<()> {
|
||||
// let mut requester = util::requester::Requester::new();
|
||||
// let text = requester.get(&base_url).await.unwrap();
|
||||
// let networks_div = text.split("class=\"sites__list\"").collect::<Vec<&str>>()[1]
|
||||
// .split("</div>")
|
||||
// .collect::<Vec<&str>>()[0];
|
||||
// for network_element in
|
||||
// networks_div.split("sites__item").collect::<Vec<&str>>()[1..].to_vec()
|
||||
// {
|
||||
// if network_element.contains("sites__all") {
|
||||
// continue;
|
||||
// }
|
||||
// let network_url = network_element.split("href=\"").collect::<Vec<&str>>()[1]
|
||||
// .split("\"")
|
||||
// .collect::<Vec<&str>>()[0];
|
||||
// let network_id = network_url.split("/").collect::<Vec<&str>>()[4].to_string();
|
||||
// let network_name = network_element.split(">").collect::<Vec<&str>>()[1]
|
||||
// .split("<")
|
||||
// .collect::<Vec<&str>>()[0]
|
||||
// .to_string();
|
||||
// Self::push_unique(
|
||||
// &networks,
|
||||
// FilterOption {
|
||||
// id: network_id,
|
||||
// title: network_name,
|
||||
// },
|
||||
// );
|
||||
// }
|
||||
// return Ok(());
|
||||
// }
|
||||
|
||||
// Push one item with minimal lock time and dedup by id
|
||||
// fn push_unique(target: &Arc<RwLock<Vec<FilterOption>>>, item: FilterOption) {
|
||||
// if let Ok(mut vec) = target.write() {
|
||||
// if !vec.iter().any(|x| x.id == item.id) {
|
||||
// vec.push(item);
|
||||
// // Optional: keep it sorted for nicer UX
|
||||
// // vec.sort_by(|a,b| a.title.cmp(&b.title));
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
fn build_channel(&self, clientversion: ClientVersion) -> Channel {
|
||||
let _ = clientversion;
|
||||
// let sites: Vec<FilterOption> = self
|
||||
// .sites
|
||||
// .read()
|
||||
// .map(|g| g.clone()) // or: .map(|g| g.to_vec())
|
||||
// .unwrap_or_default(); // or: .unwrap_or_else(|_| Vec::new())
|
||||
|
||||
// let networks: Vec<FilterOption> = self
|
||||
// .networks
|
||||
// .read()
|
||||
// .map(|g| g.clone()) // or: .map(|g| g.to_vec())
|
||||
// .unwrap_or_default(); // or: .unwrap_or_else(|_| Vec::new())
|
||||
|
||||
// let stars: Vec<FilterOption> = self
|
||||
// .stars
|
||||
// .read()
|
||||
// .map(|g| g.clone()) // or: .map(|g| g.to_vec())
|
||||
// .unwrap_or_default(); // or: .unwrap_or_else(|_| Vec::new())
|
||||
|
||||
Channel {
|
||||
id: "tnaflix".to_string(),
|
||||
name: "TnAflix".to_string(),
|
||||
description: "Just Tits and Ass".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=www.tnaflix.com".to_string(),
|
||||
status: "active".to_string(),
|
||||
categories: vec![],
|
||||
options: vec![
|
||||
ChannelOption {
|
||||
id: "sort".to_string(),
|
||||
title: "Sort".to_string(),
|
||||
description: "Sort the Videos".to_string(),
|
||||
systemImage: "list.number".to_string(),
|
||||
colorName: "blue".to_string(),
|
||||
options: vec![
|
||||
FilterOption {
|
||||
id: "new".into(),
|
||||
title: "New".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "featured".into(),
|
||||
title: "Featured".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "toprated".into(),
|
||||
title: "Top Rated".into(),
|
||||
},
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
ChannelOption {
|
||||
id: "duration".to_string(),
|
||||
title: "Duration".to_string(),
|
||||
description: "Length of the Videos".to_string(),
|
||||
systemImage: "timer".to_string(),
|
||||
colorName: "green".to_string(),
|
||||
options: vec![
|
||||
FilterOption {
|
||||
id: "all".into(),
|
||||
title: "All".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "short".into(),
|
||||
title: "Short (1-3 min)".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "medium".into(),
|
||||
title: "Medium (3-10 min)".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "long".into(),
|
||||
title: "Long (10-30 min)".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "full".into(),
|
||||
title: "Full length (30+ min)".into(),
|
||||
},
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
// ChannelOption {
|
||||
// id: "sites".to_string(),
|
||||
// title: "Sites".to_string(),
|
||||
// description: "Filter for different Sites".to_string(),
|
||||
// systemImage: "rectangle.stack".to_string(),
|
||||
// colorName: "green".to_string(),
|
||||
// options: sites,
|
||||
// multiSelect: false,
|
||||
// },
|
||||
// ChannelOption {
|
||||
// id: "networks".to_string(),
|
||||
// title: "Networks".to_string(),
|
||||
// description: "Filter for different Networks".to_string(),
|
||||
// systemImage: "list.dash".to_string(),
|
||||
// colorName: "purple".to_string(),
|
||||
// options: networks,
|
||||
// multiSelect: false,
|
||||
// },
|
||||
// ChannelOption {
|
||||
// id: "stars".to_string(),
|
||||
// title: "Stars".to_string(),
|
||||
// description: "Filter for different Pornstars".to_string(),
|
||||
// systemImage: "star.fill".to_string(),
|
||||
// colorName: "yellow".to_string(),
|
||||
// options: stars,
|
||||
// multiSelect: false,
|
||||
// },
|
||||
],
|
||||
nsfw: true,
|
||||
cacheDuration: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
sort: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let sort_string: String = match sort {
|
||||
"featured" => "featured".to_string(),
|
||||
"toprated" => "toprated".to_string(),
|
||||
_ => "new".to_string(),
|
||||
};
|
||||
let duration_string: String = match options.duration.unwrap_or("all".to_string()).as_str() {
|
||||
"short" => "short".to_string(),
|
||||
"medium" => "medium".to_string(),
|
||||
"long" => "long".to_string(),
|
||||
"full" => "full".to_string(),
|
||||
_ => "all".to_string(),
|
||||
};
|
||||
// if options.network.is_some()
|
||||
// && !options.network.as_ref().unwrap().is_empty()
|
||||
// && options.network.as_ref().unwrap() != "all"
|
||||
// {
|
||||
// sort_string = format!(
|
||||
// "networks/{}{}",
|
||||
// options.network.as_ref().unwrap(),
|
||||
// alt_sort_string
|
||||
// );
|
||||
// }
|
||||
// if options.sites.is_some()
|
||||
// && !options.sites.as_ref().unwrap().is_empty()
|
||||
// && options.sites.as_ref().unwrap() != "all"
|
||||
// {
|
||||
// sort_string = format!(
|
||||
// "sites/{}{}",
|
||||
// options.sites.as_ref().unwrap(),
|
||||
// alt_sort_string
|
||||
// );
|
||||
// }
|
||||
// if options.stars.is_some()
|
||||
// && !options.stars.as_ref().unwrap().is_empty()
|
||||
// && options.stars.as_ref().unwrap() != "all"
|
||||
// {
|
||||
// sort_string = format!(
|
||||
// "models/{}{}",
|
||||
// options.stars.as_ref().unwrap(),
|
||||
// alt_sort_string
|
||||
// );
|
||||
// }
|
||||
let video_url = format!(
|
||||
"{}/{}/{}?d={}",
|
||||
self.url, sort_string, page, duration_string
|
||||
);
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
items.clone()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
let text = requester.get(&video_url, None).await.unwrap();
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
async fn query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
query: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let search_string = query.to_string().to_lowercase().trim().replace(" ", "+");
|
||||
let duration_string: String = match options.duration.unwrap_or("all".to_string()).as_str() {
|
||||
"short" => "short".to_string(),
|
||||
"medium" => "medium".to_string(),
|
||||
"long" => "long".to_string(),
|
||||
"full" => "full".to_string(),
|
||||
_ => "all".to_string(),
|
||||
};
|
||||
let video_url = format!(
|
||||
"{}/search?what={}&d={}&page={}",
|
||||
self.url, search_string, duration_string, page
|
||||
);
|
||||
// Check our Video Cache. If the result is younger than 1 hour, we return it.
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
let _ = cache.check().await;
|
||||
return Ok(items.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
|
||||
let text = requester.get(&video_url, None).await.unwrap();
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
fn get_video_items_from_html(&self, html: String) -> Vec<VideoItem> {
|
||||
if html.is_empty() {
|
||||
println!("HTML is empty");
|
||||
return vec![];
|
||||
}
|
||||
let mut items: Vec<VideoItem> = Vec::new();
|
||||
let raw_videos = html.split("pagination ").collect::<Vec<&str>>()[0]
|
||||
.split("row video-list")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("col-xs-6 col-md-4 col-xl-3 mb-3")
|
||||
.collect::<Vec<&str>>()[1..]
|
||||
.to_vec();
|
||||
for video_segment in &raw_videos {
|
||||
// let vid = video_segment.split("\n").collect::<Vec<&str>>();
|
||||
// for (index, line) in vid.iter().enumerate() {
|
||||
// println!("Line {}: {}", index, line);
|
||||
// }
|
||||
let video_url: String = video_segment.split(" href=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let mut title = video_segment
|
||||
.split("class=\"video-title text-break\">")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.trim()
|
||||
.to_string();
|
||||
// html decode
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
let id = video_url.split("/").collect::<Vec<&str>>()[5].to_string();
|
||||
|
||||
let thumb = match video_segment.contains("data-src=\""){
|
||||
true => video_segment.split("data-src=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string(),
|
||||
false => video_segment.split("<img src=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string(),
|
||||
};
|
||||
let raw_duration = video_segment
|
||||
.split("thumb-icon video-duration\">")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let duration = parse_time_to_seconds(raw_duration.as_str()).unwrap_or(0) as u32;
|
||||
let views = match video_segment.contains("icon-eye\"></i>") {
|
||||
true => parse_abbreviated_number(
|
||||
video_segment
|
||||
.split("icon-eye\"></i>")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.trim(),
|
||||
)
|
||||
.unwrap_or(0) as u32,
|
||||
false => 0,
|
||||
};
|
||||
|
||||
let preview = video_segment
|
||||
.split("data-trailer=\"")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
|
||||
let video_item = VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
video_url.to_string(),
|
||||
"tnaflix".to_string(),
|
||||
thumb,
|
||||
duration,
|
||||
)
|
||||
.views(views)
|
||||
.preview(preview);
|
||||
items.push(video_item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for TnaflixProvider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
pool: DbPool,
|
||||
sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
per_page: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let _ = per_page;
|
||||
let _ = pool;
|
||||
let videos: std::result::Result<Vec<VideoItem>, Error> = match query {
|
||||
Some(q) => {
|
||||
self.query(cache, page.parse::<u8>().unwrap_or(1), &q, options)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
self.get(cache, page.parse::<u8>().unwrap_or(1), &sort, options)
|
||||
.await
|
||||
}
|
||||
};
|
||||
match videos {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
println!("Error fetching videos: {}", e);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
fn get_channel(&self, clientversion: ClientVersion) -> Option<crate::status::Channel> {
|
||||
Some(self.build_channel(clientversion))
|
||||
}
|
||||
}
|
||||
284
src/providers/xxdbx.rs
Normal file
@@ -0,0 +1,284 @@
|
||||
use crate::DbPool;
|
||||
use crate::api::ClientVersion;
|
||||
use crate::providers::Provider;
|
||||
use crate::status::*;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::time::parse_time_to_seconds;
|
||||
use crate::videos::{ServerOptions, VideoItem};
|
||||
use async_trait::async_trait;
|
||||
use error_chain::error_chain;
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::vec;
|
||||
use regex::Regex;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid_date(s: &str) -> bool {
|
||||
// Regex: strict yyyy-mm-dd (no validation of real calendar dates, just format)
|
||||
let re = Regex::new(r"^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$").unwrap();
|
||||
re.is_match(s)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct XxdbxProvider {
|
||||
url: String,
|
||||
stars: Arc<RwLock<Vec<String>>>,
|
||||
channels: Arc<RwLock<Vec<String>>>,
|
||||
}
|
||||
impl XxdbxProvider {
|
||||
pub fn new() -> Self {
|
||||
let provider = XxdbxProvider {
|
||||
url: "https://xxdbx.com".to_string(),
|
||||
stars: Arc::new(RwLock::new(vec![])),
|
||||
channels: Arc::new(RwLock::new(vec![])),
|
||||
};
|
||||
provider
|
||||
}
|
||||
|
||||
fn build_channel(&self, clientversion: ClientVersion) -> Channel {
|
||||
let _ = clientversion;
|
||||
Channel {
|
||||
id: "xxdbx".to_string(),
|
||||
name: "xxdbx".to_string(),
|
||||
description: "XXX Video Database".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=xxdbx.com".to_string(),
|
||||
status: "active".to_string(),
|
||||
categories: vec![],
|
||||
options: vec![
|
||||
ChannelOption {
|
||||
id: "sort".to_string(),
|
||||
title: "Sort".to_string(),
|
||||
description: "Sort the Videos".to_string(),
|
||||
systemImage: "list.number".to_string(),
|
||||
colorName: "blue".to_string(),
|
||||
options: vec![
|
||||
FilterOption {
|
||||
id: "new".into(),
|
||||
title: "New".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "popular".into(),
|
||||
title: "Most Popular".into(),
|
||||
},
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
nsfw: true,
|
||||
cacheDuration: Some(1800),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
sort: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let sort_string: String = match sort {
|
||||
"popular" => "most-popular".to_string(),
|
||||
_ => "".to_string(),
|
||||
};
|
||||
let video_url = format!(
|
||||
"{}/{}?page={}",
|
||||
self.url, sort_string, page
|
||||
);
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
items.clone()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
let text = requester.get(&video_url, None).await.unwrap();
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
async fn query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
query: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let search_string = query.trim().to_string();
|
||||
let mut search_type = "search";
|
||||
|
||||
if self.channels.read().unwrap().iter().map(|s|s.to_ascii_lowercase()).collect::<Vec<String>>().contains(&search_string.to_ascii_lowercase()) {
|
||||
search_type = "channels";
|
||||
} else if self.stars.read().unwrap().iter().map(|s|s.to_ascii_lowercase()).collect::<Vec<String>>().contains(&search_string.to_ascii_lowercase()) {
|
||||
search_type = "stars";
|
||||
} else if is_valid_date(&search_string){
|
||||
search_type = "dates";
|
||||
}
|
||||
|
||||
let video_url = format!(
|
||||
"{}/{}/{}?page={}",
|
||||
self.url, search_type, search_string, page
|
||||
);
|
||||
// Check our Video Cache. If the result is younger than 1 hour, we return it.
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
let _ = cache.check().await;
|
||||
return Ok(items.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
|
||||
let text = requester.get(&video_url, None).await.unwrap();
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
fn get_video_items_from_html(&self, html: String) -> Vec<VideoItem> {
|
||||
if html.is_empty() || html.contains("404 Not Found") {
|
||||
return vec![];
|
||||
}
|
||||
let mut items: Vec<VideoItem> = Vec::new();
|
||||
let raw_videos = html.split("</article>").collect::<Vec<&str>>()[0]
|
||||
.split("<div class=\"vids\">")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("<div class=\"v\">")
|
||||
.collect::<Vec<&str>>()[1..]
|
||||
.to_vec();
|
||||
for video_segment in &raw_videos {
|
||||
// let vid = video_segment.split("\n").collect::<Vec<&str>>();
|
||||
// for (index, line) in vid.iter().enumerate() {
|
||||
// println!("Line {}: {}\n\n", index, line);
|
||||
// }
|
||||
let video_url: String = format!("{}{}", self.url, video_segment.split("<a href=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string());
|
||||
let mut title = video_segment
|
||||
.split("<div class=\"v_title\">")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.trim()
|
||||
.to_string();
|
||||
// html decode
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
let id = video_url.split("/").collect::<Vec<&str>>()[4].to_string();
|
||||
|
||||
let thumb = format!("https:{}", video_segment.split("<img ").collect::<Vec<&str>>()[1]
|
||||
.split("src=\"").collect::<Vec<&str>>().last().unwrap()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string());
|
||||
let raw_duration = video_segment
|
||||
.split("<div class=\"v_dur\">")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let duration = parse_time_to_seconds(raw_duration.as_str()).unwrap_or(0) as u32;
|
||||
|
||||
let preview = format!("https:{}",video_segment
|
||||
.split("data-preview=\"")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string());
|
||||
let tags = video_segment.split("<div class=\"v_tags\">").collect::<Vec<&str>>()[1]
|
||||
.split("</div>").collect::<Vec<&str>>()[0]
|
||||
.split("<a href=\"")
|
||||
.collect::<Vec<&str>>()[1..]
|
||||
.into_iter().map(|s| s.split("\"").collect::<Vec<&str>>()[0].replace("%20"," ").to_string()).collect::<Vec<String>>();
|
||||
for tag in tags.clone() {
|
||||
let shorted_tag = tag.split("/").collect::<Vec<&str>>()[2].to_string();
|
||||
if tag.contains("channels") && self.channels.read().unwrap().contains(&shorted_tag) == false {
|
||||
self.channels.write().unwrap().push(shorted_tag.clone());
|
||||
}
|
||||
if tag.contains("stars") && self.stars.read().unwrap().contains(&shorted_tag) == false {
|
||||
self.stars.write().unwrap().push(shorted_tag.clone());
|
||||
}
|
||||
}
|
||||
let video_item = VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
video_url.to_string(),
|
||||
"xxdbx".to_string(),
|
||||
thumb,
|
||||
duration,
|
||||
)
|
||||
.tags(tags.into_iter().map(|s| s.split("/").collect::<Vec<&str>>().last().unwrap().to_string()).collect::<Vec<String>>())
|
||||
.preview(preview);
|
||||
items.push(video_item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for XxdbxProvider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
pool: DbPool,
|
||||
sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
per_page: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let _ = per_page;
|
||||
let _ = pool;
|
||||
let videos: std::result::Result<Vec<VideoItem>, Error> = match query {
|
||||
Some(q) => {
|
||||
self.query(cache, page.parse::<u8>().unwrap_or(1), &q, options)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
self.get(cache, page.parse::<u8>().unwrap_or(1), &sort, options)
|
||||
.await
|
||||
}
|
||||
};
|
||||
match videos {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
println!("Error fetching videos: {}", e);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
fn get_channel(&self, clientversion: ClientVersion) -> Option<crate::status::Channel> {
|
||||
Some(self.build_channel(clientversion))
|
||||
}
|
||||
}
|
||||
215
src/providers/xxthots.rs
Normal file
@@ -0,0 +1,215 @@
|
||||
use crate::DbPool;
|
||||
use crate::providers::Provider;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::parse_abbreviated_number;
|
||||
use crate::util::time::parse_time_to_seconds;
|
||||
use crate::videos::{ServerOptions, VideoItem};
|
||||
use async_trait::async_trait;
|
||||
use error_chain::error_chain;
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
use std::vec;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct XxthotsProvider {
|
||||
url: String,
|
||||
}
|
||||
impl XxthotsProvider {
|
||||
pub fn new() -> Self {
|
||||
XxthotsProvider {
|
||||
url: "https://xxthots.com".to_string(),
|
||||
}
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
sort: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let sort_string = match sort {
|
||||
"popular" => "/most-popular",
|
||||
"top-rated" => "/top-rated",
|
||||
_ => "/latest-updates/",
|
||||
};
|
||||
|
||||
let list_str = match sort {
|
||||
"popular" => "list_videos_common_videos_list",
|
||||
"top-rated" => "list_videos_common_videos_list",
|
||||
_ => "list_videos_most_recent_videos",
|
||||
};
|
||||
let video_url = format!(
|
||||
"{}{}?mode=async^&function=get_block^&block_id={}^&from={}",
|
||||
self.url, sort_string, list_str, page
|
||||
);
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
items.clone()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
let text = requester.get(&video_url, None).await.unwrap();
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
async fn query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
query: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let search_string = query.to_lowercase().trim().replace(" ", "-");
|
||||
let video_url = format!(
|
||||
"{}/search/{}/?mode=async&function=get_block&block_id=list_videos_videos_list_search_result&category_ids=&sort_by=&from_videos={}&from_albums={}&",
|
||||
self.url, search_string, page, page
|
||||
);
|
||||
// Check our Video Cache. If the result is younger than 1 hour, we return it.
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
let _ = cache.check().await;
|
||||
return Ok(items.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
let text = requester.get(&video_url, None).await.unwrap();
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
fn get_video_items_from_html(&self, html: String) -> Vec<VideoItem> {
|
||||
if html.is_empty() {
|
||||
println!("HTML is empty");
|
||||
return vec![];
|
||||
}
|
||||
let mut items: Vec<VideoItem> = Vec::new();
|
||||
let raw_videos = html
|
||||
.split("<div class=\"pagination\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.split("<div class=\"thumb thumb_rel item \">")
|
||||
.collect::<Vec<&str>>()[1..]
|
||||
.to_vec();
|
||||
for video_segment in &raw_videos {
|
||||
// let vid = video_segment.split("\n").collect::<Vec<&str>>();
|
||||
// for (index, line) in vid.iter().enumerate() {
|
||||
// println!("Line {}: {}", index, line);
|
||||
// }
|
||||
let video_url: String = video_segment.split("<a href=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let mut title = video_segment.split("\" title=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
// html decode
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
let id = video_url.split("/").collect::<Vec<&str>>()[4].to_string();
|
||||
let raw_duration = video_segment
|
||||
.split("<div class=\"time\">")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let duration = parse_time_to_seconds(&raw_duration).unwrap_or(0) as u32;
|
||||
|
||||
let thumb = video_segment
|
||||
.split("<img class=\"lazy-load")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("data-original=\"")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
|
||||
let views_part = video_segment
|
||||
.split("svg-icon icon-eye")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("</i>")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let views = parse_abbreviated_number(&views_part).unwrap_or(0) as u32;
|
||||
|
||||
let video_item = VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
video_url.to_string(),
|
||||
"xxthots".to_string(),
|
||||
thumb,
|
||||
duration,
|
||||
)
|
||||
.views(views);
|
||||
items.push(video_item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for XxthotsProvider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
pool: DbPool,
|
||||
sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
per_page: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let _ = options;
|
||||
let _ = per_page;
|
||||
let _ = pool;
|
||||
let videos: std::result::Result<Vec<VideoItem>, Error> = match query {
|
||||
Some(q) => {
|
||||
self.query(cache, page.parse::<u8>().unwrap_or(1), &q, options)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
self.get(cache, page.parse::<u8>().unwrap_or(1), &sort, options)
|
||||
.await
|
||||
}
|
||||
};
|
||||
match videos {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
println!("Error fetching videos: {}", e);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
204
src/providers/youjizz.rs
Normal file
@@ -0,0 +1,204 @@
|
||||
use crate::util::parse_abbreviated_number;
|
||||
use crate::DbPool;
|
||||
use crate::providers::Provider;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::time::parse_time_to_seconds;
|
||||
use crate::videos::{ServerOptions, VideoItem};
|
||||
use error_chain::error_chain;
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
use std::vec;
|
||||
use async_trait::async_trait;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct YoujizzProvider {
|
||||
url: String,
|
||||
}
|
||||
impl YoujizzProvider {
|
||||
pub fn new() -> Self {
|
||||
YoujizzProvider {
|
||||
url: "https://www.youjizz.com".to_string(),
|
||||
}
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
sort: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let sort_string = match sort {
|
||||
"popular" => "/most-popular",
|
||||
"top-rated" => "/top-rated",
|
||||
"top-rated-week" => "/top-rated-week",
|
||||
"top-rated-month" => "/top-rated-month",
|
||||
"trending" => "/trending",
|
||||
"random" => "/random",
|
||||
_ => "/newest-clips",
|
||||
};
|
||||
let video_url = format!("{}{}/{}.html", self.url, sort_string, page);
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
items.clone()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
|
||||
let text = requester.get(&video_url, None).await.unwrap();
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
async fn query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
query: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let video_url = format!("{}/search/{}-{}.html", self.url, query.to_lowercase().trim(), page);
|
||||
// Check our Video Cache. If the result is younger than 1 hour, we return it.
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
let _ = cache.check().await;
|
||||
return Ok(items.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let mut requester = options.requester.clone().unwrap();
|
||||
|
||||
let text = requester.get(&video_url, None).await.unwrap();
|
||||
let video_items: Vec<VideoItem> = self.get_video_items_from_html(text.clone());
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
} else {
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
fn get_video_items_from_html(&self, html: String) -> Vec<VideoItem> {
|
||||
if html.is_empty() {
|
||||
println!("HTML is empty");
|
||||
return vec![];
|
||||
}
|
||||
let mut items: Vec<VideoItem> = Vec::new();
|
||||
let raw_videos = html.split("class=\"mobile-only\"").collect::<Vec<&str>>()[0]
|
||||
.split("class=\"default video-item\"")
|
||||
.collect::<Vec<&str>>()[1..]
|
||||
.to_vec();
|
||||
for video_segment in &raw_videos {
|
||||
// let vid = video_segment.split("\n").collect::<Vec<&str>>();
|
||||
// for (index, line) in vid.iter().enumerate() {
|
||||
// println!("Line {}: {}", index, line);
|
||||
// }
|
||||
// if video_segment.contains(" src=\"https://cdne-static.cdn1122.com/app/1/images/spacer.gif") {
|
||||
// println!("Skipping video segment due to placeholder thumbnail");
|
||||
// continue;
|
||||
// }
|
||||
let video_url: String = format!("{}{}",self.url, video_segment.split("href=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0].to_string());
|
||||
let mut title = video_segment.split("class=\"video-title\">").collect::<Vec<&str>>()[1]
|
||||
.split(">").collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
// html decode
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
let id = video_url.split("/").collect::<Vec<&str>>()[4].to_string();
|
||||
|
||||
let thumb = format!("https:{}",video_segment.split("<img ").collect::<Vec<&str>>()[1]
|
||||
.split("data-original=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string());
|
||||
let raw_duration = video_segment.split("fa fa-clock-o\"></i> ").collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
let duration = parse_time_to_seconds(raw_duration.as_str()).unwrap_or(0) as u32;
|
||||
let views = parse_abbreviated_number(video_segment.split("format-views\">").collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string().as_str()).unwrap_or(0) as u32;
|
||||
|
||||
let video_item = VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
video_url.to_string(),
|
||||
"youjizz".to_string(),
|
||||
thumb,
|
||||
duration,
|
||||
)
|
||||
.views(views)
|
||||
;
|
||||
items.push(video_item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for YoujizzProvider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
pool: DbPool,
|
||||
sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
per_page: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let _ = per_page;
|
||||
let _ = pool;
|
||||
let videos: std::result::Result<Vec<VideoItem>, Error> = match query {
|
||||
Some(q) => {
|
||||
self.query(cache, page.parse::<u8>().unwrap_or(1), &q,options)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
self.get(cache, page.parse::<u8>().unwrap_or(1), &sort, options)
|
||||
.await
|
||||
}
|
||||
};
|
||||
match videos {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
println!("Error fetching videos: {}", e);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
54
src/proxies/hanimecdn.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use ntex::{
|
||||
http::Response,
|
||||
web::{self, HttpRequest, error},
|
||||
};
|
||||
use ntex::http::header::{CONTENT_LENGTH, CONTENT_TYPE};
|
||||
|
||||
use crate::util::requester::Requester;
|
||||
|
||||
pub async fn get_image(
|
||||
req: HttpRequest,
|
||||
requester: web::types::State<Requester>,
|
||||
) -> Result<impl web::Responder, web::Error> {
|
||||
let endpoint = req.match_info().query("endpoint").to_string();
|
||||
let image_url = format!("https://hanime-cdn.com/{}", endpoint);
|
||||
|
||||
let upstream = match requester
|
||||
.get_ref()
|
||||
.clone()
|
||||
.get_raw_with_headers(
|
||||
image_url.as_str(),
|
||||
vec![("Referer".to_string(), "https://hanime.tv/".to_string())],
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(_) => return Ok(web::HttpResponse::NotFound().finish()),
|
||||
};
|
||||
|
||||
let status = upstream.status();
|
||||
let headers = upstream.headers().clone();
|
||||
|
||||
// Read body from upstream
|
||||
let bytes = upstream.bytes().await.map_err(error::ErrorBadGateway)?;
|
||||
|
||||
// Build response and forward headers
|
||||
let mut resp = Response::build(status);
|
||||
|
||||
if let Some(ct) = headers.get(CONTENT_TYPE) {
|
||||
if let Ok(ct_str) = ct.to_str() {
|
||||
resp.set_header(CONTENT_TYPE, ct_str);
|
||||
}
|
||||
}
|
||||
if let Some(cl) = headers.get(CONTENT_LENGTH) {
|
||||
if let Ok(cl_str) = cl.to_str() {
|
||||
resp.set_header(CONTENT_LENGTH, cl_str);
|
||||
}
|
||||
}
|
||||
|
||||
// Either zero-copy to ntex Bytes...
|
||||
// Ok(resp.body(NtexBytes::from(bytes)))
|
||||
|
||||
// ...or simple & compatible:
|
||||
Ok(resp.body(bytes.to_vec()))
|
||||
}
|
||||
64
src/proxies/javtiful.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
use ntex::web;
|
||||
use wreq::Version;
|
||||
|
||||
use crate::util::requester::Requester;
|
||||
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JavtifulProxy {
|
||||
}
|
||||
|
||||
impl JavtifulProxy {
|
||||
pub fn new() -> Self {
|
||||
JavtifulProxy {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_video_url(
|
||||
&self,
|
||||
url: String,
|
||||
requester: web::types::State<Requester>,
|
||||
) -> String {
|
||||
let mut requester = requester.get_ref().clone();
|
||||
let url = "https://javtiful.com/".to_string() + &url;
|
||||
let text = requester.get(&url, None).await.unwrap_or("".to_string());
|
||||
if text.is_empty() {
|
||||
return "".to_string();
|
||||
}
|
||||
let video_id = url
|
||||
.split('/')
|
||||
.nth(4)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let token = text.split("data-csrf-token=\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let form = wreq::multipart::Form::new()
|
||||
.text("video_id", video_id.clone())
|
||||
.text("pid_c", "".to_string())
|
||||
.text("token", token.clone());
|
||||
let resp = match requester
|
||||
.post_multipart(
|
||||
"https://javtiful.com/ajax/get_cdn",
|
||||
form,
|
||||
vec![("Referer".to_string(), url.to_string())],
|
||||
Some(Version::HTTP_11),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(_) => return "".to_string(),
|
||||
};
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
let json: serde_json::Value = serde_json::from_str(&text).unwrap_or(serde_json::Value::Null);
|
||||
let video_url = json.get("playlists")
|
||||
.map(|v| v.to_string().replace("\"", ""))
|
||||
.unwrap_or_default();
|
||||
|
||||
return video_url;
|
||||
}
|
||||
}
|
||||
35
src/proxies/mod.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use ntex::web;
|
||||
|
||||
use crate::{proxies::sxyprn::SxyprnProxy, util::requester::Requester};
|
||||
|
||||
pub mod sxyprn;
|
||||
pub mod hanimecdn;
|
||||
pub mod javtiful;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AnyProxy {
|
||||
Sxyprn(SxyprnProxy),
|
||||
Javtiful(javtiful::JavtifulProxy),
|
||||
}
|
||||
|
||||
pub trait Proxy {
|
||||
async fn get_video_url(
|
||||
&self,
|
||||
url: String,
|
||||
requester: web::types::State<Requester>,
|
||||
) -> String;
|
||||
}
|
||||
|
||||
|
||||
impl Proxy for AnyProxy {
|
||||
async fn get_video_url(
|
||||
&self,
|
||||
url: String,
|
||||
requester: web::types::State<Requester>,
|
||||
) -> String {
|
||||
match self {
|
||||
AnyProxy::Sxyprn(p) => p.get_video_url(url, requester).await,
|
||||
AnyProxy::Javtiful(p) => p.get_video_url(url, requester).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
78
src/proxies/sxyprn.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
use ntex::web;
|
||||
|
||||
use crate::util::requester::Requester;
|
||||
|
||||
/// Extracts digits from a string and sums them.
|
||||
fn ssut51(arg: &str) -> u32 {
|
||||
arg.chars()
|
||||
.filter(|c| c.is_ascii_digit())
|
||||
.map(|c| c.to_digit(10).unwrap())
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Encodes a token: "<sum1>-<host>-<sum2>" using Base64 URL-safe variant.
|
||||
fn boo(sum1: u32, sum2: u32) -> String {
|
||||
let raw = format!("{}-{}-{}", sum1, "sxyprn.com", sum2);
|
||||
let encoded = general_purpose::STANDARD.encode(raw);
|
||||
|
||||
// Replace + → -, / → _, = → .
|
||||
encoded
|
||||
.replace('+', "-")
|
||||
.replace('/', "_")
|
||||
.replace('=', ".")
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SxyprnProxy {
|
||||
}
|
||||
|
||||
impl SxyprnProxy {
|
||||
pub fn new() -> Self {
|
||||
SxyprnProxy {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_video_url(
|
||||
&self,
|
||||
url: String,
|
||||
requester: web::types::State<Requester>,
|
||||
) -> String {
|
||||
let mut requester = requester.get_ref().clone();
|
||||
let url = "https://sxyprn.com/".to_string() + &url;
|
||||
let text = requester.get(&url, None).await.unwrap_or("".to_string());
|
||||
if text.is_empty() {
|
||||
return "".to_string();
|
||||
}
|
||||
let data_string = text.split("data-vnfo='").collect::<Vec<&str>>()[1]
|
||||
.split("\":\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"}").collect::<Vec<&str>>()[0].replace("\\","");
|
||||
//println!("src: {}",data_string);
|
||||
let mut tmp = data_string
|
||||
.split("/")
|
||||
.map(|s| s.to_string())
|
||||
.collect::<Vec<String>>();
|
||||
//println!("tmp: {:?}",tmp);
|
||||
tmp[1] = format!("{}8/{}", tmp[1], boo(ssut51(tmp[6].as_str()), ssut51(tmp[7].as_str())));
|
||||
|
||||
//println!("tmp[1]: {:?}",tmp[1]);
|
||||
//preda
|
||||
tmp[5] = format!(
|
||||
"{}",
|
||||
tmp[5].parse::<u32>().unwrap() - ssut51(tmp[6].as_str()) - ssut51(tmp[7].as_str())
|
||||
);
|
||||
//println!("tmp: {:?}",tmp);
|
||||
let sxyprn_video_url = format!("https://sxyprn.com{}",tmp.join("/"));
|
||||
|
||||
let response = requester.get_raw(&sxyprn_video_url).await;
|
||||
match response {
|
||||
Ok(resp) => {
|
||||
return format!("https:{}", resp.headers().get("Location").unwrap().to_str().unwrap_or("").to_string());
|
||||
},
|
||||
Err(e) => {
|
||||
println!("Error fetching video URL: {}", e);
|
||||
}
|
||||
}
|
||||
return "".to_string();
|
||||
}
|
||||
}
|
||||
47
src/proxy.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use ntex::web::{self, HttpRequest};
|
||||
|
||||
use crate::proxies::javtiful::JavtifulProxy;
|
||||
use crate::proxies::sxyprn::SxyprnProxy;
|
||||
use crate::util::requester::Requester;
|
||||
use crate::proxies::*;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::resource("/sxyprn/{endpoint}*")
|
||||
.route(web::post().to(proxy2redirect))
|
||||
.route(web::get().to(proxy2redirect)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/javtiful/{endpoint}*")
|
||||
.route(web::post().to(proxy2redirect))
|
||||
.route(web::get().to(proxy2redirect)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/hanime-cdn/{endpoint}*")
|
||||
.route(web::post().to(crate::proxies::hanimecdn::get_image))
|
||||
.route(web::get().to(crate::proxies::hanimecdn::get_image)),
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
|
||||
async fn proxy2redirect(req: HttpRequest,
|
||||
requester: web::types::State<Requester>,) -> Result<impl web::Responder, web::Error> {
|
||||
let proxy = get_proxy(req.uri().to_string().split("/").collect::<Vec<&str>>()[2]).unwrap();
|
||||
let endpoint = req.match_info().query("endpoint").to_string();
|
||||
let video_url = match proxy.get_video_url(endpoint, requester).await{
|
||||
url if url != "" => url,
|
||||
_ => "Error".to_string(),
|
||||
};
|
||||
Ok(web::HttpResponse::Found()
|
||||
.header("Location", video_url)
|
||||
.finish())
|
||||
}
|
||||
|
||||
fn get_proxy(proxy: &str) -> Option<AnyProxy> {
|
||||
match proxy {
|
||||
"sxyprn" => Some(AnyProxy::Sxyprn(SxyprnProxy::new())),
|
||||
"javtiful" => Some(AnyProxy::Javtiful(JavtifulProxy::new())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
8
src/schema.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
// @generated automatically by Diesel CLI.
|
||||
|
||||
diesel::table! {
|
||||
videos (id) {
|
||||
id -> Text,
|
||||
url -> Text,
|
||||
}
|
||||
}
|
||||
128
src/status.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct Notice {
|
||||
pub status: String, //"info",
|
||||
pub message: String, //"test message",
|
||||
pub details: String, //"test details",
|
||||
pub priority: bool, //false,
|
||||
pub url: String, //"hottub:\/\/upgrade?source=hottub"
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct Channel {
|
||||
pub id: String, //"hottub",
|
||||
pub name: String, //"Hot Tub Pro",
|
||||
pub description: String, //"Advanced search, filters, and more. Consider supporting the development of Hot Tub by upgrading to Hot Tub Pro.",
|
||||
pub premium: bool, //true,
|
||||
pub favicon: String, //"https:\/\/www.google.com/s2/favicons?sz=64&domain=https:\/\/hottubapp.io",
|
||||
pub status: String, //"active",
|
||||
pub categories: Vec<String>, //[],
|
||||
pub options: Vec<ChannelOption>,
|
||||
pub nsfw: bool, //true
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cacheDuration: Option<u32>, //Some(86400)
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct ChannelOption {
|
||||
pub id: String, //"channels",
|
||||
pub title: String, //"Sites",
|
||||
pub description: String, //"Websites included in search results.",
|
||||
pub systemImage: String, //"network",
|
||||
pub colorName: String, //"purple",
|
||||
pub options: Vec<FilterOption>, //[],
|
||||
pub multiSelect: bool, //true
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, Debug, Clone)]
|
||||
pub struct FilterOption{
|
||||
pub id: String, //"sort",
|
||||
pub title: String, //"Sort",
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct Options {
|
||||
pub id: String, //"sort",
|
||||
pub title: String, //"Sort",
|
||||
pub description: String, //"Sort the videos by new or old.",
|
||||
pub systemImage: String, //"sort.image",
|
||||
pub colorName: String, //"blue",
|
||||
pub options: Vec<OptionValue>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct OptionValue {
|
||||
pub id: String, //"new",
|
||||
pub title: String, //"New",
|
||||
pub description: Option<String>, //"Sort the videos by new or old."
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct Subscription {
|
||||
pub status: String, //"incomplete"
|
||||
}
|
||||
|
||||
impl Subscription {
|
||||
pub fn new() -> Self {
|
||||
Subscription {
|
||||
status: "incomplete".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct Status {
|
||||
pub id: String, //"spacemoehrehottub",
|
||||
pub name: String, //"SpaceMoehre's Hot Tub",
|
||||
pub subtitle: String, //"More sources, more videos, more fun!",
|
||||
pub description: String, //"SpaceMoehre's Hot Tub is a source for finding videos from various websites.",
|
||||
pub iconUrl: String, //"https://hottubapp.io/files/hottub/appicon.png",
|
||||
pub color: String, //"\#A700FF",
|
||||
pub status: String, //"normal",
|
||||
pub notices: Vec<Notice>,
|
||||
pub channels: Vec<Channel>,
|
||||
pub subscription: Subscription,
|
||||
pub nsfw: bool,
|
||||
pub categories: Vec<String>,
|
||||
pub options: Vec<Options>,
|
||||
pub filtersFooter: String, //"Help us improve our algorithms by selecting the categories that best describe you. These will not necessarily affect your search results, but will help us tailor the app to your interests."
|
||||
}
|
||||
impl Status {
|
||||
pub fn new() -> Self {
|
||||
Status {
|
||||
id: "spacemoehre".to_string(),
|
||||
name: "SpaceMoehre's Hot Tub".to_string(),
|
||||
subtitle: "More sources, more videos, more fun!".to_string(),
|
||||
description:
|
||||
"SpaceMoehre's Hot Tub is a source for finding videos from various websites."
|
||||
.to_string(),
|
||||
iconUrl: "https://hottubapp.io/files/hottub/appicon.png".to_string(),
|
||||
color: "#FFA500".to_string(),
|
||||
status: "normal".to_string(),
|
||||
notices: vec![],
|
||||
channels: vec![],
|
||||
subscription: Subscription::new(),
|
||||
nsfw: true,
|
||||
categories: vec![],
|
||||
options: vec![],
|
||||
filtersFooter:
|
||||
"Help us improve our algorithms by giving us your feedback on the hottub discord."
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
pub fn add_notice(&mut self, notice: Notice) {
|
||||
self.notices.push(notice);
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
pub fn add_channel(&mut self, channel: Channel) {
|
||||
self.channels.push(channel);
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
pub fn add_option(&mut self, option: Options) {
|
||||
self.options.push(option);
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
pub fn add_category(&mut self, category: String) {
|
||||
self.categories.push(category);
|
||||
}
|
||||
}
|
||||
77
src/util/cache.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use std::time::SystemTime;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::videos::VideoItem;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct VideoCache {
|
||||
cache: Arc<Mutex<std::collections::HashMap<String, (SystemTime, Vec<VideoItem>)>>>, // url -> time+Items
|
||||
max_size: usize,
|
||||
}
|
||||
impl VideoCache {
|
||||
pub fn new() -> Self {
|
||||
VideoCache {
|
||||
cache: Arc::new(Mutex::new(std::collections::HashMap::new())),
|
||||
max_size: 100,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn max_size(&mut self, size: usize) -> &mut Self {
|
||||
self.max_size = size;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<(SystemTime, Vec<VideoItem>)> {
|
||||
let cache = self.cache.lock().ok()?;
|
||||
cache.get(key).cloned()
|
||||
}
|
||||
|
||||
pub fn insert(&self, key: String, value: Vec<VideoItem>) {
|
||||
if let Ok(mut cache) = self.cache.lock() {
|
||||
if cache.len() >= self.max_size {
|
||||
// Simple eviction policy: remove a random entry
|
||||
if let Some(first_key) = cache.keys().next().cloned() {
|
||||
cache.remove(&first_key);
|
||||
}
|
||||
}
|
||||
cache.insert(key.clone(), (SystemTime::now(), value.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&self, key: &str) {
|
||||
if let Ok(mut cache) = self.cache.lock() {
|
||||
cache.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> Option<Vec<(String, (SystemTime, Vec<VideoItem>))>> {
|
||||
if let Ok(cache) = self.cache.lock() {
|
||||
// Return a cloned vector of the cache entries
|
||||
return Some(cache.iter().map(|(k, v)| (k.clone(), v.clone())).collect());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn check(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let iter = match self.entries() {
|
||||
Some(iter) => iter,
|
||||
None => {
|
||||
return Err(Box::new(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
"Could not get entries",
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
for (key, (time, _items)) in iter {
|
||||
if let Ok(elapsed) = time.elapsed() {
|
||||
if elapsed > Duration::from_secs(60 * 60) {
|
||||
self.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
107
src/util/discord.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::Write as _;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use dashmap::DashMap;
|
||||
use once_cell::sync::Lazy;
|
||||
use serde_json::json;
|
||||
use crate::util::requester;
|
||||
|
||||
// Global cache: Map<ErrorSignature, LastSentTimestamp>
|
||||
static ERROR_CACHE: Lazy<DashMap<String, u64>> = Lazy::new(DashMap::new);
|
||||
// const COOLDOWN_SECONDS: u64 = 3600; // 1 Hour cooldown
|
||||
|
||||
pub fn format_error_chain(err: &dyn Error) -> String {
|
||||
let mut chain_str = String::new();
|
||||
let mut current_err: Option<&dyn Error> = Some(err);
|
||||
let mut index = 1;
|
||||
while let Some(e) = current_err {
|
||||
let _ = writeln!(chain_str, "{}. {}", index, e);
|
||||
current_err = e.source();
|
||||
index += 1;
|
||||
}
|
||||
chain_str
|
||||
}
|
||||
|
||||
pub async fn send_discord_error_report(
|
||||
error_msg: String,
|
||||
error_chain: Option<String>,
|
||||
context: Option<&str>,
|
||||
extra_info: Option<&str>,
|
||||
file: &str,
|
||||
line: u32,
|
||||
module: &str,
|
||||
) {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
// --- Deduplication Logic ---
|
||||
// Create a unique key based on error content and location
|
||||
let error_signature = format!("{}-{}-{}", error_msg, file, line);
|
||||
|
||||
if let Some(_) = ERROR_CACHE.get(&error_signature) {
|
||||
// if now - *last_sent < COOLDOWN_SECONDS {
|
||||
// Error is still in cooldown, skip sending
|
||||
return;
|
||||
// }
|
||||
}
|
||||
|
||||
// Update the cache with the current timestamp
|
||||
ERROR_CACHE.insert(error_signature, now);
|
||||
// ---------------------------
|
||||
|
||||
let webhook_url = match std::env::var("DISCORD_WEBHOOK") {
|
||||
Ok(url) => url,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
const MAX_FIELD: usize = 1024;
|
||||
let truncate = |s: &str| {
|
||||
if s.len() > MAX_FIELD {
|
||||
format!("{}…", &s[..MAX_FIELD - 1])
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let payload = json!({
|
||||
"embeds": [{
|
||||
"title": "🚨 Rust Error Report",
|
||||
"color": 0xE74C3C,
|
||||
"fields": [
|
||||
{
|
||||
"name": "Error",
|
||||
"value": format!("```{}```", truncate(&error_msg)),
|
||||
"inline": false
|
||||
},
|
||||
{
|
||||
"name": "Error Chain",
|
||||
"value": truncate(&error_chain.unwrap_or_else(|| "No chain provided".to_string())),
|
||||
"inline": false
|
||||
},
|
||||
{
|
||||
"name": "Location",
|
||||
"value": format!("`{}`:{}\n`{}`", file, line, module),
|
||||
"inline": true
|
||||
},
|
||||
{
|
||||
"name": "Context",
|
||||
"value": truncate(context.unwrap_or("n/a")),
|
||||
"inline": true
|
||||
},
|
||||
{
|
||||
"name": "Extra Info",
|
||||
"value": truncate(extra_info.unwrap_or("n/a")),
|
||||
"inline": false
|
||||
}
|
||||
],
|
||||
"footer": {
|
||||
"text": format!("Unix time: {} | Cooldown active", now)
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
let mut requester = requester::Requester::new();
|
||||
let _ = requester.post_json(&webhook_url, &payload, vec![]).await;
|
||||
}
|
||||
105
src/util/flaresolverr.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
use std::{collections::HashMap, env};
|
||||
|
||||
use serde_json::json;
|
||||
use wreq::{Client, Proxy};
|
||||
use wreq_util::Emulation;
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug)]
|
||||
pub struct FlareSolverrRequest {
|
||||
pub cmd: String,
|
||||
pub url: String,
|
||||
pub maxTimeout: u32,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug)]
|
||||
pub struct FlaresolverrCookie {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
pub domain: String,
|
||||
pub path: String,
|
||||
pub expires: f64,
|
||||
pub size: u64,
|
||||
pub httpOnly: bool,
|
||||
pub secure: bool,
|
||||
pub session: bool,
|
||||
pub sameSite: Option<String>,
|
||||
pub priority: String,
|
||||
pub sameParty: bool,
|
||||
pub sourceScheme: String,
|
||||
pub sourcePort: u32,
|
||||
pub partitionKey: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug)]
|
||||
pub struct FlareSolverrSolution {
|
||||
pub url: String,
|
||||
pub status: u32,
|
||||
pub response: String,
|
||||
pub headers: HashMap<String, String>,
|
||||
pub cookies: Vec<FlaresolverrCookie>,
|
||||
pub userAgent: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug)]
|
||||
pub struct FlareSolverrResponse {
|
||||
pub status: String,
|
||||
pub message: String,
|
||||
pub solution: FlareSolverrSolution,
|
||||
pub startTimestamp: u64,
|
||||
pub endTimestamp: u64,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
pub struct Flaresolverr {
|
||||
url: String,
|
||||
proxy: bool,
|
||||
}
|
||||
|
||||
impl Flaresolverr {
|
||||
pub fn new(url: String) -> Self {
|
||||
Self {
|
||||
url,
|
||||
proxy: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_proxy(&mut self, proxy: bool) {
|
||||
self.proxy = proxy;
|
||||
}
|
||||
|
||||
pub async fn solve(
|
||||
&self,
|
||||
request: FlareSolverrRequest,
|
||||
) -> Result<FlareSolverrResponse, Box<dyn std::error::Error>> {
|
||||
let client = Client::builder()
|
||||
.emulation(Emulation::Firefox136)
|
||||
.build()?;
|
||||
|
||||
let mut req = client
|
||||
.post(&self.url)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&json!({
|
||||
"cmd": request.cmd,
|
||||
"url": request.url,
|
||||
"maxTimeout": request.maxTimeout,
|
||||
}));
|
||||
|
||||
if self.proxy {
|
||||
if let Ok(proxy_url) = env::var("BURP_URL") {
|
||||
match Proxy::all(&proxy_url) {
|
||||
Ok(proxy) => {
|
||||
req = req.proxy(proxy);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Invalid proxy URL '{}': {}", proxy_url, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let response = req.send().await?;
|
||||
|
||||
let body = response.json::<FlareSolverrResponse>().await?;
|
||||
Ok(body)
|
||||
}
|
||||
}
|
||||
45
src/util/mod.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
pub mod time;
|
||||
pub mod flaresolverr;
|
||||
pub mod cache;
|
||||
pub mod requester;
|
||||
pub mod discord;
|
||||
|
||||
pub fn parse_abbreviated_number(s: &str) -> Option<u32> {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (num_part, suffix) = s
|
||||
.chars()
|
||||
.partition::<String, _>(|c| c.is_ascii_digit() || *c == '.');
|
||||
let multiplier = match suffix.trim().to_ascii_uppercase().as_str() {
|
||||
"K" => 1_000.0,
|
||||
"M" => 1_000_000.0,
|
||||
"B" => 1_000_000_000.0,
|
||||
"" => 1.0,
|
||||
_ => return None,
|
||||
};
|
||||
num_part.parse::<f64>().ok().map(|n| (n * multiplier) as u32)
|
||||
}
|
||||
|
||||
pub fn interleave<T: Clone>(lists: &[Vec<T>]) -> Vec<T> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
if lists.is_empty() {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Find the maximum length among the lists
|
||||
let max_len = lists.iter().map(|l| l.len()).max().unwrap_or(0);
|
||||
|
||||
// Interleave elements
|
||||
for i in 0..max_len {
|
||||
for list in lists {
|
||||
if let Some(item) = list.get(i) {
|
||||
result.push(item.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
269
src/util/requester.rs
Normal file
@@ -0,0 +1,269 @@
|
||||
use serde::Serialize;
|
||||
use wreq::multipart::Form;
|
||||
use std::env;
|
||||
use wreq::Client;
|
||||
use wreq::Proxy;
|
||||
use wreq::Response;
|
||||
use wreq::Version;
|
||||
use wreq::header::HeaderValue;
|
||||
use wreq::redirect::Policy;
|
||||
use wreq_util::Emulation;
|
||||
|
||||
use crate::util::flaresolverr::FlareSolverrRequest;
|
||||
use crate::util::flaresolverr::Flaresolverr;
|
||||
|
||||
// A Send + Sync error type for all async paths
|
||||
type AnyErr = Box<dyn std::error::Error + Send + Sync + 'static>;
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
|
||||
pub struct Requester {
|
||||
#[serde(skip)]
|
||||
client: Client,
|
||||
proxy: bool,
|
||||
flaresolverr_session: Option<String>,
|
||||
}
|
||||
|
||||
impl Requester {
|
||||
pub fn new() -> Self {
|
||||
let client = Client::builder()
|
||||
.cert_verification(false)
|
||||
.emulation(Emulation::Firefox136)
|
||||
.cookie_store(true)
|
||||
.redirect(Policy::default())
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
|
||||
Requester {
|
||||
client,
|
||||
proxy: false,
|
||||
flaresolverr_session: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_proxy(&mut self, proxy: bool) {
|
||||
if proxy {
|
||||
println!("Proxy enabled");
|
||||
}
|
||||
self.proxy = proxy;
|
||||
}
|
||||
|
||||
pub async fn get_raw(&mut self, url: &str) -> Result<Response, wreq::Error> {
|
||||
let client = Client::builder()
|
||||
.cert_verification(false)
|
||||
.emulation(Emulation::Firefox136)
|
||||
.cookie_store(true)
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
|
||||
let mut request = client.get(url).version(Version::HTTP_11);
|
||||
|
||||
if self.proxy {
|
||||
if let Ok(proxy_url) = env::var("BURP_URL") {
|
||||
let proxy = Proxy::all(&proxy_url).unwrap();
|
||||
request = request.proxy(proxy);
|
||||
}
|
||||
}
|
||||
|
||||
request.send().await
|
||||
}
|
||||
|
||||
pub async fn get_raw_with_headers(
|
||||
&mut self,
|
||||
url: &str,
|
||||
headers: Vec<(String, String)>,
|
||||
) -> Result<Response, wreq::Error> {
|
||||
let client = Client::builder()
|
||||
.cert_verification(false)
|
||||
.emulation(Emulation::Firefox136)
|
||||
.cookie_store(true)
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
|
||||
let mut request = client.get(url).version(Version::HTTP_11);
|
||||
|
||||
if self.proxy {
|
||||
if let Ok(proxy_url) = env::var("BURP_URL") {
|
||||
let proxy = Proxy::all(&proxy_url).unwrap();
|
||||
request = request.proxy(proxy);
|
||||
}
|
||||
}
|
||||
// Set custom headers
|
||||
for (key, value) in headers.iter() {
|
||||
request = request.header(key, value);
|
||||
}
|
||||
request.send().await
|
||||
}
|
||||
|
||||
pub async fn post_json<S>(
|
||||
&mut self,
|
||||
url: &str,
|
||||
data: &S,
|
||||
headers: Vec<(String, String)>,
|
||||
) -> Result<Response, wreq::Error>
|
||||
where
|
||||
S: Serialize + ?Sized,
|
||||
{
|
||||
let mut request = self.client.post(url).version(Version::HTTP_11).json(data);
|
||||
|
||||
// Set custom headers
|
||||
for (key, value) in headers.iter() {
|
||||
request = request.header(key, value);
|
||||
}
|
||||
|
||||
if self.proxy {
|
||||
if let Ok(proxy_url) = env::var("BURP_URL") {
|
||||
let proxy = Proxy::all(&proxy_url).unwrap();
|
||||
request = request.proxy(proxy);
|
||||
}
|
||||
}
|
||||
|
||||
request.send().await
|
||||
}
|
||||
|
||||
pub async fn post(
|
||||
&mut self,
|
||||
url: &str,
|
||||
data: &str,
|
||||
headers: Vec<(&str, &str)>,
|
||||
) -> Result<Response, wreq::Error> {
|
||||
let mut request = self.client.post(url).version(Version::HTTP_11).body(data.to_string());
|
||||
|
||||
// Set custom headers
|
||||
for (key, value) in headers.iter() {
|
||||
request = request.header(key.to_string(), value.to_string());
|
||||
}
|
||||
|
||||
if self.proxy {
|
||||
if let Ok(proxy_url) = env::var("BURP_URL") {
|
||||
let proxy = Proxy::all(&proxy_url).unwrap();
|
||||
request = request.proxy(proxy);
|
||||
}
|
||||
}
|
||||
|
||||
request.send().await
|
||||
}
|
||||
|
||||
pub async fn post_multipart(
|
||||
&mut self,
|
||||
url: &str,
|
||||
form: Form,
|
||||
headers: Vec<(String, String)>,
|
||||
_http_version: Option<Version>,
|
||||
) -> Result<Response, wreq::Error>
|
||||
{
|
||||
let http_version = match _http_version {
|
||||
Some(v) => v,
|
||||
None => Version::HTTP_11,
|
||||
};
|
||||
|
||||
let mut request = self.client.post(url).multipart(form).version(http_version);
|
||||
|
||||
// Set custom headers
|
||||
for (key, value) in headers.iter() {
|
||||
request = request.header(key, value);
|
||||
}
|
||||
|
||||
if self.proxy {
|
||||
if let Ok(proxy_url) = env::var("BURP_URL") {
|
||||
let proxy = Proxy::all(&proxy_url).unwrap();
|
||||
request = request.proxy(proxy);
|
||||
}
|
||||
}
|
||||
|
||||
request.send().await
|
||||
}
|
||||
|
||||
pub async fn get(&mut self, url: &str, _http_version: Option<Version>) -> Result<String, AnyErr> {
|
||||
let http_version = match _http_version {
|
||||
Some(v) => v,
|
||||
None => Version::HTTP_11,
|
||||
};
|
||||
loop {
|
||||
let mut request = self.client.get(url).version(http_version);
|
||||
if self.proxy {
|
||||
if let Ok(proxy_url) = env::var("BURP_URL") {
|
||||
let proxy = Proxy::all(&proxy_url).unwrap();
|
||||
request = request.proxy(proxy);
|
||||
}
|
||||
}
|
||||
let response = request.send().await?;
|
||||
if response.status().is_success() || response.status().as_u16() == 404 {
|
||||
return Ok(response.text().await?);
|
||||
}
|
||||
if response.status().as_u16() == 429 {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
continue;
|
||||
} else {
|
||||
println!(
|
||||
"Direct request to {} failed with status: {}",
|
||||
url,
|
||||
response.status()
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// If direct request failed, try FlareSolverr. Map its error to a Send+Sync error immediately,
|
||||
// so no non-Send error value lives across later `.await`s.
|
||||
let flare_url = env::var("FLARE_URL").expect("FLARE_URL not set");
|
||||
let mut flare = Flaresolverr::new(flare_url);
|
||||
if self.proxy && env::var("BURP_URL").is_ok() {
|
||||
flare.set_proxy(true);
|
||||
}
|
||||
|
||||
let res = flare
|
||||
.solve(FlareSolverrRequest {
|
||||
cmd: "request.get".to_string(),
|
||||
url: url.to_string(),
|
||||
maxTimeout: 60000,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| -> AnyErr { format!("Failed to solve FlareSolverr: {e}").into() })?;
|
||||
|
||||
// Rebuild client and apply UA/cookies from FlareSolverr
|
||||
let cookie_origin = url.split('/').take(3).collect::<Vec<&str>>().join("/");
|
||||
|
||||
self.client = Client::builder()
|
||||
.cert_verification(false)
|
||||
.emulation(Emulation::Firefox136)
|
||||
.cookie_store(true)
|
||||
.redirect(Policy::default())
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
|
||||
let useragent = res.solution.userAgent;
|
||||
self.client
|
||||
.update()
|
||||
.headers(|headers| {
|
||||
headers.insert("User-Agent", HeaderValue::from_str(&useragent).unwrap());
|
||||
})
|
||||
.apply()
|
||||
.unwrap();
|
||||
|
||||
if let Ok(origin) = url::Url::parse(&cookie_origin) {
|
||||
for cookie in res.solution.cookies {
|
||||
let header =
|
||||
HeaderValue::from_str(&format!("{}={}", cookie.name, cookie.value)).unwrap();
|
||||
self.client.set_cookie(&origin, header);
|
||||
}
|
||||
}
|
||||
|
||||
// Retry the original URL with the updated client & (optional) proxy
|
||||
let mut request = self.client.get(url).version(Version::HTTP_11);
|
||||
if self.proxy {
|
||||
if let Ok(proxy_url) = env::var("BURP_URL") {
|
||||
let proxy = Proxy::all(&proxy_url).unwrap();
|
||||
request = request.proxy(proxy);
|
||||
}
|
||||
}
|
||||
|
||||
let response = request.send().await?;
|
||||
if response.status().is_success() {
|
||||
return Ok(response.text().await?);
|
||||
}
|
||||
|
||||
// Fall back to FlareSolverr-provided body
|
||||
Ok(res.solution.response)
|
||||
}
|
||||
}
|
||||
19
src/util/time.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
pub fn parse_time_to_seconds(s: &str) -> Option<i64> {
|
||||
let parts: Vec<_> = s.trim().split(':').collect();
|
||||
match parts.len() {
|
||||
2 => {
|
||||
// MM:SS
|
||||
let minutes: i64 = parts[0].parse().ok()?;
|
||||
let seconds: i64 = parts[1].parse().ok()?;
|
||||
Some(minutes * 60 + seconds)
|
||||
}
|
||||
3 => {
|
||||
// HH:MM:SS
|
||||
let hours: i64 = parts[0].parse().ok()?;
|
||||
let minutes: i64 = parts[1].parse().ok()?;
|
||||
let seconds: i64 = parts[2].parse().ok()?;
|
||||
Some(hours * 3600 + minutes * 60 + seconds)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
382
src/videos.rs
Normal file
@@ -0,0 +1,382 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::util::requester::Requester;
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug)]
|
||||
pub struct VideosRequest {
|
||||
//"versionInstallDate":"2025-06-03T18:20:20Z","languageCode":"en","appInstallDate":"2025-06-03T18:20:20Z","server":"spacemoehre","sexu
|
||||
pub clientHash: Option<String>, // "a07b23c9b07813c65050e2a4041ca777",
|
||||
pub blockedKeywords: Option<String>, // "kittens",
|
||||
pub countryCode: Option<String>, // "DE",
|
||||
pub clientVersion: Option<String>, // "2.1.4-22b",
|
||||
pub timestamp: Option<String>, // "1748976686",
|
||||
pub blockedUploaders: Option<String>, // "",
|
||||
pub anonId: Option<String>, // "1AB8A060-A47D-47EF-B9CB-63980ED84C8A",
|
||||
pub debugTools: Option<bool>, // false,
|
||||
pub versionInstallDate: Option<String>, // "2025-06-03T18:20:20Z",
|
||||
pub languageCode: Option<String>, // "en",
|
||||
pub appInstallDate: Option<String>, // "2025-06-03T18:20:20Z",
|
||||
pub server: Option<String>, // "spacemoehre",
|
||||
pub sexuality: Option<String>, // "straight",
|
||||
pub channel: Option<String>, //"youtube",
|
||||
pub sort: Option<String>, //"new",
|
||||
pub query: Option<String>, //"kittens",
|
||||
pub page: Option<String>, //1,
|
||||
pub perPage: Option<String>, //10,
|
||||
// Your server's global options will be sent in the videos request
|
||||
// pub flavor: "mint chocolate chip"
|
||||
pub featured: Option<String>, // "featured",
|
||||
pub category: Option<String>, // "pmv"
|
||||
pub sites: Option<String>, //
|
||||
pub filter: Option<String>, //
|
||||
pub language: Option<String>, //
|
||||
pub networks: Option<String>, //
|
||||
pub stars: Option<String>, //
|
||||
pub categories: Option<String>,
|
||||
pub duration: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
|
||||
pub struct ServerOptions {
|
||||
pub featured: Option<String>, // "featured",
|
||||
pub category: Option<String>, // "pmv"
|
||||
pub sites: Option<String>, //
|
||||
pub filter: Option<String>,
|
||||
pub language: Option<String>, // "en"
|
||||
pub requester: Option<Requester>,
|
||||
pub network: Option<String>, //
|
||||
pub stars: Option<String>, //
|
||||
pub categories: Option<String>, //
|
||||
pub duration: Option<String>, //
|
||||
pub sort: Option<String>, //
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, Debug)]
|
||||
pub struct PageInfo {
|
||||
pub hasNextPage: bool, // true,
|
||||
pub resultsPerPage: u32, // 10
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
|
||||
pub struct VideoEmbed {
|
||||
pub html: String,
|
||||
pub source: String,
|
||||
}
|
||||
impl VideoEmbed {
|
||||
pub fn new(html: String, source: String) -> Self {
|
||||
VideoEmbed { html, source }
|
||||
}
|
||||
}
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
|
||||
pub struct VideoItem {
|
||||
pub duration: u32, // 110,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub views: Option<u32>, // 14622653,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rating: Option<f32>, // 0.0,
|
||||
pub id: String, // "c85017ca87477168d648727753c4ded8a35f173e22ef93743e707b296becb299",
|
||||
pub title: String, // "20 Minutes of Adorable Kittens BEST Compilation",
|
||||
pub url: String, // "https://www.youtube.com/watch?v=y0sF5xhGreA",
|
||||
pub channel: String, // "youtube",
|
||||
pub thumb: String, // "https://i.ytimg.com/vi/y0sF5xhGreA/hqdefault.jpg",
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub uploader: Option<String>, // "The Pet Collective",
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub uploaderUrl: Option<String>, // "https://www.youtube.com/@petcollective",
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub verified: Option<bool>, // false,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tags: Option<Vec<String>>, // [],
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub uploadedAt: Option<u64>, // 1741142954
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub formats: Option<Vec<VideoFormat>>, // Additional HTTP headers if needed
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub embed: Option<VideoEmbed>, // Optional embed information
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub preview: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspectRatio: Option<f32>,
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
impl VideoItem {
|
||||
pub fn new(
|
||||
id: String,
|
||||
title: String,
|
||||
url: String,
|
||||
channel: String,
|
||||
thumb: String,
|
||||
duration: u32,
|
||||
) -> Self {
|
||||
VideoItem {
|
||||
duration: duration, // Placeholder, adjust as needed
|
||||
views: None, // Placeholder, adjust as needed
|
||||
rating: None, // Placeholder, adjust as needed
|
||||
id,
|
||||
title,
|
||||
url,
|
||||
channel,
|
||||
thumb,
|
||||
uploader: None,
|
||||
uploaderUrl: None,
|
||||
verified: None,
|
||||
tags: None, // Placeholder, adjust as needed
|
||||
uploadedAt: None,
|
||||
formats: None, // Placeholder for formats
|
||||
embed: None, // Placeholder for embed information
|
||||
preview: None,
|
||||
aspectRatio: None,
|
||||
}
|
||||
}
|
||||
pub fn tags(mut self, tags: Vec<String>) -> Self {
|
||||
if tags.is_empty(){
|
||||
return self;
|
||||
}
|
||||
self.tags = Some(tags);
|
||||
self
|
||||
}
|
||||
pub fn uploader(mut self, uploader: String) -> Self {
|
||||
self.uploader = Some(uploader);
|
||||
self
|
||||
}
|
||||
pub fn uploader_url(mut self, uploader_url: String) -> Self {
|
||||
self.uploaderUrl = Some(uploader_url);
|
||||
self
|
||||
}
|
||||
pub fn verified(mut self, verified: bool) -> Self {
|
||||
self.verified = Some(verified);
|
||||
self
|
||||
}
|
||||
pub fn views(mut self, views: u32) -> Self {
|
||||
self.views = Some(views);
|
||||
self
|
||||
}
|
||||
pub fn rating(mut self, rating: f32) -> Self {
|
||||
self.rating = Some(rating);
|
||||
self
|
||||
}
|
||||
pub fn uploaded_at(mut self, uploaded_at: u64) -> Self {
|
||||
self.uploadedAt = Some(uploaded_at);
|
||||
self
|
||||
}
|
||||
pub fn formats(mut self, formats: Vec<VideoFormat>) -> Self {
|
||||
if formats.is_empty(){
|
||||
return self;
|
||||
}
|
||||
self.formats = Some(formats);
|
||||
self
|
||||
}
|
||||
pub fn add_format(mut self, format: VideoFormat) {
|
||||
if let Some(formats) = self.formats.as_mut() {
|
||||
formats.push(format);
|
||||
} else {
|
||||
self.formats = Some(vec![format]);
|
||||
}
|
||||
}
|
||||
pub fn embed(mut self, embed: VideoEmbed) -> Self {
|
||||
self.embed = Some(embed);
|
||||
self
|
||||
}
|
||||
pub fn preview(mut self, preview: String) -> Self {
|
||||
self.preview = Some(preview);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn aspect_ratio(mut self, aspect_ratio: f32) -> Self {
|
||||
self.aspectRatio = Some(aspect_ratio);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
|
||||
pub struct VideoFormat {
|
||||
pub url: String,
|
||||
quality: String,
|
||||
format: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
format_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
format_note: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
filesize: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
asr: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
fps: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
width: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
height: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tbr: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
language: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
language_preference: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
ext: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
vcodec: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
acodec: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
dynamic_range: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
abr: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
vbr: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
container: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
protocol: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
audio_ext: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
video_ext: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
resolution: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
http_headers: Option<HashMap<String, String>>,
|
||||
}
|
||||
impl VideoFormat {
|
||||
pub fn new(url: String, quality: String, format: String) -> Self {
|
||||
let _ = format;
|
||||
VideoFormat {
|
||||
url,
|
||||
quality,
|
||||
format: format, // Default format
|
||||
format_id: Some("mp4-1080".to_string()),
|
||||
format_note: None,
|
||||
filesize: None,
|
||||
asr: None,
|
||||
fps: None,
|
||||
width: None,
|
||||
height: None,
|
||||
tbr: None,
|
||||
language: None,
|
||||
language_preference: None,
|
||||
ext: Some("mp4".to_string()),
|
||||
vcodec: None,
|
||||
acodec: None,
|
||||
dynamic_range: None,
|
||||
abr: None,
|
||||
vbr: None,
|
||||
container: None,
|
||||
protocol: Some("m3u8_native".to_string()),
|
||||
audio_ext: Some("none".to_string()),
|
||||
video_ext: Some("mp4".to_string()),
|
||||
resolution: None,
|
||||
http_headers: None,
|
||||
}
|
||||
}
|
||||
pub fn add_http_header(&mut self, key: String, value: String) {
|
||||
if self.http_headers.is_none() {
|
||||
self.http_headers = Some(HashMap::new());
|
||||
}
|
||||
if let Some(headers) = &mut self.http_headers {
|
||||
headers.insert(key, value);
|
||||
}
|
||||
}
|
||||
pub fn http_header(&mut self, key: String, value: String) -> Self {
|
||||
if self.http_headers.is_none() {
|
||||
self.http_headers = Some(HashMap::new());
|
||||
}
|
||||
if let Some(headers) = &mut self.http_headers {
|
||||
headers.insert(key, value);
|
||||
}
|
||||
self.to_owned()
|
||||
}
|
||||
pub fn format_id(mut self, format_id: String) -> Self {
|
||||
self.format_id = Some(format_id);
|
||||
self
|
||||
}
|
||||
pub fn format_note(mut self, format_note: String) -> Self {
|
||||
self.format_note = Some(format_note);
|
||||
self
|
||||
}
|
||||
// pub fn filesize(mut self, filesize: u32) -> Self {
|
||||
// self.filesize = Some(filesize);
|
||||
// self
|
||||
// }
|
||||
// pub fn asr(mut self, asr: u32) -> Self {
|
||||
// self.asr = Some(asr);
|
||||
// self
|
||||
// }
|
||||
// pub fn fps(mut self, fps: u32) -> Self {
|
||||
// self.fps = Some(fps);
|
||||
// self
|
||||
// }
|
||||
// pub fn width(mut self, width: u32) -> Self {
|
||||
// self.width = Some(width);
|
||||
// self
|
||||
// }
|
||||
// pub fn height(mut self, height: u32) -> Self {
|
||||
// self.height = Some(height);
|
||||
// self
|
||||
// }
|
||||
// pub fn tbr(mut self, tbr: u32) -> Self {
|
||||
// self.tbr = Some(tbr);
|
||||
// self
|
||||
// }
|
||||
// pub fn language(mut self, language: String) -> Self {
|
||||
// self.language = Some(language);
|
||||
// self
|
||||
// }
|
||||
// pub fn language_preference(mut self, language_preference: u32) -> Self {
|
||||
// self.language_preference = Some(language_preference);
|
||||
// self
|
||||
// }
|
||||
// pub fn ext(mut self, ext: String) -> Self {
|
||||
// self.ext = Some(ext);
|
||||
// self
|
||||
// }
|
||||
// pub fn vcodec(mut self, vcodec: String) -> Self {
|
||||
// self.vcodec = Some(vcodec);
|
||||
// self
|
||||
// }
|
||||
// pub fn acodec(mut self, acodec: String) -> Self {
|
||||
// self.acodec = Some(acodec);
|
||||
// self
|
||||
// }
|
||||
// pub fn dynamic_range(mut self, dynamic_range: String) -> Self {
|
||||
// self.dynamic_range = Some(dynamic_range);
|
||||
// self
|
||||
// }
|
||||
// pub fn abr(mut self, abr: u32) -> Self {
|
||||
// self.abr = Some(abr);
|
||||
// self
|
||||
// }
|
||||
// pub fn vbr(mut self, vbr: u32) -> Self {
|
||||
// self.vbr = Some(vbr);
|
||||
// self
|
||||
// }
|
||||
// pub fn container(mut self, container: String) -> Self {
|
||||
// self.container = Some(container);
|
||||
// self
|
||||
// }
|
||||
// pub fn protocol(mut self, protocol: String) -> Self {
|
||||
// self.protocol = Some(protocol);
|
||||
// self
|
||||
// }
|
||||
// pub fn audio_ext(mut self, audio_ext: String) -> Self {
|
||||
// self.audio_ext = Some(audio_ext);
|
||||
// self
|
||||
// }
|
||||
// pub fn video_ext(mut self, video_ext: String) -> Self {
|
||||
// self.video_ext = Some(video_ext);
|
||||
// self
|
||||
// }
|
||||
// pub fn resolution(mut self, resolution: String) -> Self {
|
||||
// self.resolution = Some(resolution);
|
||||
// self
|
||||
// }
|
||||
// pub fn http_headers(mut self, http_headers: HashMap<String, String>) -> Self {
|
||||
// self.http_headers = Some(http_headers);
|
||||
// self
|
||||
// }
|
||||
}
|
||||
#[derive(serde::Serialize, Debug)]
|
||||
pub struct Videos {
|
||||
pub pageInfo: PageInfo,
|
||||
pub items: Vec<VideoItem>,
|
||||
}
|
||||
BIN
static/favicon.ico
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
2
supervisord/burpsuite.sh
Normal file
@@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
/headless/.venv/bin/python3 /app/burp/start_burp.py
|
||||
1
supervisord/hottub.sh
Normal file
@@ -0,0 +1 @@
|
||||
/app/target/release/hottub
|
||||
34
supervisord/supervisord.conf
Normal file
@@ -0,0 +1,34 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
logfile=/dev/null
|
||||
loglevel=error
|
||||
stdout_logfile_maxbytes = 0
|
||||
stderr_logfile_maxbytes = 0
|
||||
|
||||
[program:hottub]
|
||||
command=bash /app/supervisord/hottub.sh
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stderr_logfile=/dev/stderr
|
||||
directory=/app
|
||||
stdout_logfile_maxbytes = 0
|
||||
stderr_logfile_maxbytes = 0
|
||||
|
||||
[program:vnc]
|
||||
command=bash -c '[ "$PROXY" = "1" ] && exec /dockerstartup/vnc_startup.sh --wait || echo "Skipping vnc (PROXY != 1)"'
|
||||
autostart=true
|
||||
autorestart=false
|
||||
stdout_logfile=/dev/stdout
|
||||
stderr_logfile=/dev/stderr
|
||||
stdout_logfile_maxbytes = 0
|
||||
stderr_logfile_maxbytes = 0
|
||||
|
||||
[program:burpsuite]
|
||||
command=bash -c '[ "$PROXY" = "1" ] && exec /app/supervisord/burpsuite.sh || echo "Skipping burpsuite (PROXY != 1)"'
|
||||
autostart=true
|
||||
autorestart=false
|
||||
stdout_logfile=/dev/stdout
|
||||
stderr_logfile=/dev/stderr
|
||||
stdout_logfile_maxbytes = 0
|
||||
stderr_logfile_maxbytes = 0
|
||||