Compare commits
470 Commits
master
...
4b017fafdf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b017fafdf | ||
|
|
b07d269154 | ||
|
|
e2796bfd71 | ||
|
|
38acb2b5a5 | ||
|
|
fb9098c689 | ||
|
|
c4be911d8b | ||
|
|
ef459fa6b5 | ||
|
|
bdc7d61121 | ||
|
|
80207efa73 | ||
|
|
01831c70e7 | ||
|
|
429fb16fbd | ||
|
|
4df2a672b7 | ||
|
|
bc984a4791 | ||
|
|
0df84a1fac | ||
|
|
de8f88bf41 | ||
|
|
bbb1792dbe | ||
|
|
e031396459 | ||
|
|
4e95354880 | ||
|
|
243d19cec0 | ||
|
|
99fe4c947c | ||
|
|
90ce9c684b | ||
|
|
9021521c00 | ||
|
|
fbe04fc752 | ||
|
|
52f108da8e | ||
|
|
50ea0e73b7 | ||
|
|
a2d31d90a1 | ||
|
|
43594a6cfe | ||
|
|
7b66e5b28a | ||
|
|
05ea90405b | ||
|
|
9bba981796 | ||
|
|
cecc1f994b | ||
|
|
75b7241803 | ||
|
|
1b32df0c35 | ||
|
|
259a07686d | ||
|
|
46cd348148 | ||
|
|
dd7c4ec6a1 | ||
|
|
99e4a77507 | ||
|
|
2b26019a66 | ||
|
|
f88b789f25 | ||
|
|
21ef0ebf17 | ||
|
|
ce1afd9873 | ||
|
|
ce781e2099 | ||
|
|
a66f44c747 | ||
|
|
9ca9e820d9 | ||
|
|
0563a7231a | ||
|
|
3c3af70ed6 | ||
|
|
7680a93fab | ||
|
|
3a2e77436e | ||
|
|
9172941ac6 | ||
|
|
a977381b3b | ||
|
|
0d20fc7a7e | ||
|
|
0c11959d94 | ||
|
|
f8a09b0e97 | ||
|
|
9751c25b95 | ||
|
|
1f99eec5a3 | ||
|
|
448efeff1e | ||
|
|
0137313c6e | ||
|
|
6a62582c09 | ||
|
|
2e1223e519 | ||
|
|
96926563b8 | ||
|
|
2ad131f38f | ||
|
|
efb1eb3c91 | ||
|
|
967d1e8143 | ||
|
|
9d7146e705 | ||
|
|
8b54eeac81 | ||
|
|
41373bf937 | ||
|
|
c7866a1702 | ||
|
|
b875086761 | ||
|
|
c57ce2e243 | ||
|
|
2ed001801a | ||
|
|
716b775105 | ||
|
|
4c1815e0fc | ||
|
|
9fea043888 | ||
|
|
1cb9c325b4 | ||
|
|
97046f1399 | ||
|
|
4c00336919 | ||
|
|
2f8951601b | ||
|
|
63782f6a7c | ||
|
|
5be0a89e51 | ||
|
|
2627505ade | ||
|
|
76fd5a4f4f | ||
|
|
8157e223fe | ||
|
|
060d8e7937 | ||
|
|
4ad9453245 | ||
|
|
b3d10ae0d9 | ||
|
|
b8c52e059d | ||
|
|
ce13162a5f | ||
|
|
ff8d1afef6 | ||
|
|
718e7c3f78 | ||
|
|
61840b7ec5 | ||
|
|
08e94b5240 | ||
|
|
dfbcf85ddf | ||
|
|
36c482a615 | ||
|
|
4d29d19c0a | ||
|
|
00c8c99f09 | ||
|
|
becfd52e17 | ||
|
|
3fe6280f27 | ||
|
|
5105d33212 | ||
|
|
1bed8c56a0 | ||
|
|
18b4afddcc | ||
|
|
27b87d52d5 | ||
|
|
b3256a741e | ||
|
|
4860d6abff | ||
|
|
9964c11a8a | ||
|
|
eea8d9ae6f | ||
|
|
b45687d578 | ||
|
|
f4fbd62c97 | ||
|
|
2f1fd8f33a | ||
|
|
5b9ef5b279 | ||
|
|
44cfb1f208 | ||
|
|
310dfd71e9 | ||
|
|
7b1bb758e3 | ||
|
|
bf622d95a6 | ||
|
|
360b615742 | ||
|
|
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 | |||
| 7fa6bdeb3c |
3
.cargo/config.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[build]
|
||||
rustflags = ["-C", "debuginfo=1"]
|
||||
#rustc-wrapper = "sccache"
|
||||
9
.gitignore
vendored
@@ -3,6 +3,7 @@
|
||||
# will have compiled files and executables
|
||||
debug/
|
||||
target/
|
||||
.*/
|
||||
|
||||
# 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
|
||||
@@ -14,9 +15,5 @@ Cargo.lock
|
||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||
*.pdb
|
||||
|
||||
# RustRover
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# 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/
|
||||
*.db
|
||||
migrations/.keep
|
||||
|
||||
56
Cargo.toml
@@ -1,19 +1,61 @@
|
||||
[package]
|
||||
name = "hottub"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
[features]
|
||||
debug = []
|
||||
|
||||
[dependencies]
|
||||
async-trait = "0.1.88"
|
||||
awc = "3.7.0"
|
||||
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.0", features = ["tokio", "openssl"] }
|
||||
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 = "6.0.0-rc.26", features = ["cookies", "multipart", "json"] }
|
||||
wreq-util = "3.0.0-rc.10"
|
||||
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"
|
||||
reqwest = { version = "0.12.18", features = ["blocking", "json", "rustls-tls"] }
|
||||
serde = "1.0.219"
|
||||
serde_json = "1.0.140"
|
||||
rustc-hash = "2.1.1"
|
||||
async-trait = "0.1"
|
||||
regex = "1.12.2"
|
||||
titlecase = "3.6.0"
|
||||
dashmap = "6.1.0"
|
||||
lru = "0.16.3"
|
||||
rand = "0.10.0"
|
||||
chrono = "0.4.44"
|
||||
md5 = "0.8.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)'] }
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 0
|
||||
debug = 1
|
||||
codegen-units = 256
|
||||
incremental = true
|
||||
|
||||
[profile.release]
|
||||
# Make release builds faster by trading some peak perf for compile time.
|
||||
# - opt-level = 2: slightly less optimization than 3 but noticeably faster builds.
|
||||
# - codegen-units > 1: enables parallel code generation across crates.
|
||||
# - lto = false: disabling link-time optimization speeds up linking.
|
||||
# - debug = 0: skip debug info to reduce build work.
|
||||
opt-level = 3
|
||||
codegen-units = 16
|
||||
lto = false
|
||||
debug = 0
|
||||
|
||||
33
Dockerfile
Normal file
@@ -0,0 +1,33 @@
|
||||
FROM ubuntu:24.04
|
||||
# FROM consol/debian-xfce-vnc:latest
|
||||
# Switch to root user to install additional software
|
||||
USER 0
|
||||
|
||||
RUN apt update
|
||||
RUN apt install -yq yt-dlp 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
|
||||
|
||||
RUN python3 -m pip install --break-system-packages --no-cache-dir curl_cffi
|
||||
|
||||
USER 1000
|
||||
45
README.md
@@ -5,3 +5,48 @@ 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
|
||||
```
|
||||
323
SKILL.md
Normal file
@@ -0,0 +1,323 @@
|
||||
---
|
||||
name: hottub
|
||||
description: Work on the Hottub Rust server. Use this skill when you need the real build/run commands, compile-time single-provider builds, runtime env vars, API and proxy endpoint trigger examples, or yt-dlp verification steps for returned media URLs.
|
||||
---
|
||||
|
||||
# Hottub
|
||||
|
||||
Hottub is a Rust `ntex` server. The main entrypoints are:
|
||||
|
||||
- `src/main.rs`: server startup, env loading, root redirect, `/api`, `/proxy`, static files
|
||||
- `src/api.rs`: `/api/status`, `/api/videos`, `/api/test`, `/api/proxies`
|
||||
- `src/proxy.rs`: `/proxy/...` redirect and media/image proxy routes
|
||||
- `src/providers/mod.rs`: provider registry, compile-time provider selection, channel metadata
|
||||
- `src/util/requester.rs`: outbound HTTP, Burp proxy support, FlareSolverr fallback
|
||||
|
||||
## Build and run
|
||||
|
||||
Default local run:
|
||||
|
||||
```bash
|
||||
cargo run
|
||||
```
|
||||
|
||||
Run with compiled-in debug logs:
|
||||
|
||||
```bash
|
||||
cargo run --features debug
|
||||
```
|
||||
|
||||
Compile a single-provider binary:
|
||||
|
||||
```bash
|
||||
HOT_TUB_PROVIDER=hsex cargo build
|
||||
```
|
||||
|
||||
Single-provider binary with debug logs:
|
||||
|
||||
```bash
|
||||
HOT_TUB_PROVIDER=hsex cargo run --features debug
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `HOT_TUB_PROVIDER` is the preferred compile-time selector.
|
||||
- `HOTTUB_PROVIDER` is also supported as a fallback alias.
|
||||
- Single-provider builds register only that provider at compile time, so other providers are not constructed and their init paths do not run.
|
||||
- In a single-provider build, `/api/videos` requests with `"channel": "all"` are remapped to the compiled provider.
|
||||
- The server binds to `0.0.0.0:18080`.
|
||||
|
||||
Useful checks:
|
||||
|
||||
```bash
|
||||
cargo check -q
|
||||
HOT_TUB_PROVIDER=hsex cargo check -q
|
||||
HOT_TUB_PROVIDER=hsex cargo check -q --features debug
|
||||
```
|
||||
|
||||
## Environment
|
||||
|
||||
Runtime env vars:
|
||||
|
||||
- `DATABASE_URL` required. SQLite path, for example `hottub.db`.
|
||||
- `RUST_LOG` optional. Defaults to `warn` if unset.
|
||||
- `PROXY` optional. Any value other than `"0"` enables proxy mode in the shared requester.
|
||||
- `BURP_URL` optional. Outbound HTTP proxy used when `PROXY` is enabled.
|
||||
- `FLARE_URL` optional but strongly recommended for provider work. Used for FlareSolverr fallback and some providers that call it directly.
|
||||
- `DOMAIN` optional. Used for the `/` redirect target.
|
||||
- `DISCORD_WEBHOOK` optional. Enables `/api/test` and provider error reporting to Discord.
|
||||
|
||||
Build-time env vars:
|
||||
|
||||
- `HOT_TUB_PROVIDER` optional. Compile only one provider into the binary.
|
||||
- `HOTTUB_PROVIDER` optional fallback alias for the same purpose.
|
||||
|
||||
Practical `.env` baseline:
|
||||
|
||||
```dotenv
|
||||
DATABASE_URL=hottub.db
|
||||
RUST_LOG=info
|
||||
PROXY=0
|
||||
BURP_URL=http://127.0.0.1:8081
|
||||
FLARE_URL=http://127.0.0.1:8191/v1
|
||||
DOMAIN=127.0.0.1:18080
|
||||
DISCORD_WEBHOOK=
|
||||
```
|
||||
|
||||
## Endpoint surface
|
||||
|
||||
Root:
|
||||
|
||||
- `GET /`
|
||||
- Returns `302 Found`
|
||||
- Redirects to `hottub://source?url=<DOMAIN-or-request-host>`
|
||||
|
||||
Status API:
|
||||
|
||||
- `GET /api/status`
|
||||
- `POST /api/status`
|
||||
- Returns the server status and channel list
|
||||
- The `User-Agent` matters because channel visibility can depend on parsed client version
|
||||
|
||||
Videos API:
|
||||
|
||||
- `POST /api/videos`
|
||||
- Main provider execution endpoint
|
||||
- Body is JSON matching `VideosRequest` in `src/videos.rs`
|
||||
|
||||
Diagnostics:
|
||||
|
||||
- `GET /api/test`
|
||||
- Sends a Discord test error if `DISCORD_WEBHOOK` is configured
|
||||
- `GET /api/proxies`
|
||||
- Returns the current outbound proxy snapshot
|
||||
|
||||
Proxy endpoints:
|
||||
|
||||
- Redirect proxies:
|
||||
- `GET|POST /proxy/sxyprn/{endpoint}*`
|
||||
- `GET|POST /proxy/javtiful/{endpoint}*`
|
||||
- `GET|POST /proxy/spankbang/{endpoint}*`
|
||||
- `GET|POST /proxy/porndish/{endpoint}*`
|
||||
- `GET|POST /proxy/pimpbunny/{endpoint}*`
|
||||
- Media/image proxies:
|
||||
- `GET|POST /proxy/noodlemagazine/{endpoint}*`
|
||||
- `GET|POST /proxy/noodlemagazine-thumb/{endpoint}*`
|
||||
- `GET|POST /proxy/hanime-cdn/{endpoint}*`
|
||||
- `GET|POST /proxy/hqporner-thumb/{endpoint}*`
|
||||
- `GET|POST /proxy/porndish-thumb/{endpoint}*`
|
||||
- `GET|POST /proxy/pimpbunny-thumb/{endpoint}*`
|
||||
|
||||
Everything else under `/` is served from `static/`.
|
||||
|
||||
## How to trigger endpoints
|
||||
|
||||
Verify the root redirect:
|
||||
|
||||
```bash
|
||||
curl -i http://127.0.0.1:18080/
|
||||
```
|
||||
|
||||
Fetch status with a Hot Tub-like user agent:
|
||||
|
||||
```bash
|
||||
curl -s \
|
||||
-H 'User-Agent: Hot%20Tub/22c CFNetwork/1494.0.7 Darwin/23.4.0' \
|
||||
http://127.0.0.1:18080/api/status | jq
|
||||
```
|
||||
|
||||
Equivalent `POST /api/status`:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:18080/api/status | jq
|
||||
```
|
||||
|
||||
Minimal videos request:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:18080/api/videos \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'User-Agent: Hot%20Tub/22c CFNetwork/1494.0.7 Darwin/23.4.0' \
|
||||
-d '{"channel":"hsex","sort":"date","page":1,"perPage":10}' | jq
|
||||
```
|
||||
|
||||
Use `"all"` against a normal multi-provider build:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:18080/api/videos \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"channel":"all","sort":"date","page":1,"perPage":10}' | jq
|
||||
```
|
||||
|
||||
Use `"all"` against a single-provider build:
|
||||
|
||||
```bash
|
||||
HOT_TUB_PROVIDER=hsex cargo run --features debug
|
||||
curl -s http://127.0.0.1:18080/api/videos \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"channel":"all","sort":"date","page":1,"perPage":10}' | jq
|
||||
```
|
||||
|
||||
Literal query behavior:
|
||||
|
||||
- Quoted queries are treated as literal substring filters after provider fetch.
|
||||
- Leading `#` is stripped before matching.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:18080/api/videos \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"channel":"hsex","query":"\"teacher\"","page":1,"perPage":10}' | jq
|
||||
```
|
||||
|
||||
Trigger the Discord test route:
|
||||
|
||||
```bash
|
||||
curl -i http://127.0.0.1:18080/api/test
|
||||
```
|
||||
|
||||
Inspect proxy state:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:18080/api/proxies | jq
|
||||
```
|
||||
|
||||
Trigger a redirect proxy and inspect the `Location` header:
|
||||
|
||||
```bash
|
||||
curl -I 'http://127.0.0.1:18080/proxy/spankbang/some/provider/path'
|
||||
```
|
||||
|
||||
Trigger a media proxy directly:
|
||||
|
||||
```bash
|
||||
curl -I 'http://127.0.0.1:18080/proxy/noodlemagazine/some/media/path'
|
||||
```
|
||||
|
||||
## Videos request fields
|
||||
|
||||
Commonly useful request keys:
|
||||
|
||||
- `channel`
|
||||
- `sort`
|
||||
- `query`
|
||||
- `page`
|
||||
- `perPage`
|
||||
- `featured`
|
||||
- `category`
|
||||
- `sites`
|
||||
- `all_provider_sites`
|
||||
- `filter`
|
||||
- `language`
|
||||
- `networks`
|
||||
- `stars`
|
||||
- `categories`
|
||||
- `duration`
|
||||
- `sexuality`
|
||||
|
||||
Most provider debugging only needs:
|
||||
|
||||
```json
|
||||
{
|
||||
"channel": "hsex",
|
||||
"sort": "date",
|
||||
"query": null,
|
||||
"page": 1,
|
||||
"perPage": 10
|
||||
}
|
||||
```
|
||||
|
||||
## Recommended provider-debug workflow
|
||||
|
||||
1. Build only the provider you care about.
|
||||
2. Run with `--features debug`.
|
||||
3. Hit `/api/status` to confirm only the expected channel is present.
|
||||
4. Hit `/api/videos` with either the provider id or `"all"`.
|
||||
5. Inspect `.items[0].url`, `.items[0].formats`, `.items[0].thumb`, and any local `/proxy/...` URLs.
|
||||
6. Verify the media URL with `yt-dlp`.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
HOT_TUB_PROVIDER=hsex cargo run --features debug
|
||||
curl -s http://127.0.0.1:18080/api/status | jq '.channels[].id'
|
||||
curl -s http://127.0.0.1:18080/api/videos \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"channel":"all","page":1,"perPage":1}' | tee /tmp/hottub-video.json | jq
|
||||
```
|
||||
|
||||
## yt-dlp verification
|
||||
|
||||
Use `yt-dlp` to prove that a returned video URL or format is actually consumable.
|
||||
|
||||
Check the primary item URL:
|
||||
|
||||
```bash
|
||||
URL="$(jq -r '.items[0].url' /tmp/hottub-video.json)"
|
||||
yt-dlp -v --simulate "$URL"
|
||||
```
|
||||
|
||||
Prefer the first explicit format when present:
|
||||
|
||||
```bash
|
||||
FORMAT_URL="$(jq -r '.items[0].formats[0].url' /tmp/hottub-video.json)"
|
||||
yt-dlp -v -F "$FORMAT_URL"
|
||||
yt-dlp -v --simulate "$FORMAT_URL"
|
||||
```
|
||||
|
||||
If the format contains required HTTP headers, pass them through:
|
||||
|
||||
```bash
|
||||
yt-dlp -v --simulate \
|
||||
--add-header 'Referer: https://example.com/' \
|
||||
--add-header 'User-Agent: Mozilla/5.0 ...' \
|
||||
"$FORMAT_URL"
|
||||
```
|
||||
|
||||
If you want to build the command from JSON:
|
||||
|
||||
```bash
|
||||
FORMAT_URL="$(jq -r '.items[0].formats[0].url' /tmp/hottub-video.json)"
|
||||
mapfile -t HDRS < <(
|
||||
jq -r '.items[0].formats[0].http_headers // {} | to_entries[] | "--add-header=\(.key): \(.value)"' \
|
||||
/tmp/hottub-video.json
|
||||
)
|
||||
yt-dlp -v --simulate "${HDRS[@]}" "$FORMAT_URL"
|
||||
```
|
||||
|
||||
For local proxy URLs returned by Hottub, verify the server endpoint directly:
|
||||
|
||||
```bash
|
||||
LOCAL_URL="$(jq -r '.items[0].formats[0].url // .items[0].url' /tmp/hottub-video.json)"
|
||||
yt-dlp -v --simulate "$LOCAL_URL"
|
||||
```
|
||||
|
||||
## Interaction rules
|
||||
|
||||
- Prefer compile-time single-provider builds for provider work.
|
||||
- Prefer `/api/status` before `/api/videos` so you know what channels the current binary exposes.
|
||||
- When reproducing client-specific issues, send a realistic `User-Agent`.
|
||||
- When debugging fetch failures, enable `debug` and set `FLARE_URL`.
|
||||
- When debugging outbound request behavior, set `PROXY=1` and `BURP_URL=...`.
|
||||
- Use `/api/test` only when you intentionally want a Discord notification.
|
||||
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![]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
271
archive/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))
|
||||
}
|
||||
}
|
||||
434
archive/tube8.rs
Normal file
@@ -0,0 +1,434 @@
|
||||
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::vec;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Tube8Provider {
|
||||
url: String,
|
||||
sites: Arc<RwLock<Vec<FilterOption>>>,
|
||||
stars: Arc<RwLock<Vec<FilterOption>>>,
|
||||
}
|
||||
impl Tube8Provider {
|
||||
pub fn new() -> Self {
|
||||
let provider = Tube8Provider {
|
||||
url: "https://www.tube8.com".to_string(),
|
||||
sites: 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
|
||||
}
|
||||
|
||||
// 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 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: "tube8".to_string(),
|
||||
name: "Tube8".to_string(),
|
||||
description: "Tube8 Videos".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=www.tube8.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: "rating".into(),
|
||||
title: "Rating".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "mostviewed".into(),
|
||||
title: "Most Viewed".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "longest".into(),
|
||||
title: "Duration".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "newest".into(),
|
||||
title: "Newest".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: "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 {
|
||||
"mostviewed" => "most-viewed/page/".to_string(),
|
||||
"longest" => "longest/page/".to_string(),
|
||||
"newest" => "newest/page/".to_string(),
|
||||
_ => "top/page/".to_string(),
|
||||
};
|
||||
if options.sites.is_some()
|
||||
&& !options.sites.as_ref().unwrap().is_empty()
|
||||
&& options.sites.as_ref().unwrap() != "all"
|
||||
{
|
||||
sort_string = match sort {
|
||||
"mostviewed.html" => "?orderBy=mv&page=".to_string(),
|
||||
"longest.html" => "?orderBy=ln&page=".to_string(),
|
||||
"newest.html" => "?page=".to_string(),
|
||||
_ => "?orderBy=tr&page=".to_string(),
|
||||
};
|
||||
}
|
||||
if options.stars.is_some()
|
||||
&& !options.stars.as_ref().unwrap().is_empty()
|
||||
&& options.stars.as_ref().unwrap() != "all"
|
||||
{
|
||||
sort_string = match sort {
|
||||
"mostviewed.html" => "views/?page=".to_string(),
|
||||
"longest.html" => "duration/?page=".to_string(),
|
||||
"newest.html" => "?page=".to_string(),
|
||||
_ => "rating/?page=".to_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![]
|
||||
}
|
||||
};
|
||||
println!("Video URL {:?}", video_url);
|
||||
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 sort_string: String = match options.sort.as_ref().unwrap().as_str() {
|
||||
"mostviewed.html" => "&orderby=views&page=".to_string(),
|
||||
"longest.html" => "&orderby=longest&page=".to_string(),
|
||||
"newest.html" => "&orderby=newest&page=".to_string(),
|
||||
_ => "&orderby=rating&page=".to_string(),
|
||||
};
|
||||
let mut search_string = query.to_string().to_ascii_lowercase().trim().to_string();
|
||||
let mut video_url = format!(
|
||||
"{}/searches.html/?q={}{}{}",
|
||||
self.url, query, sort_string, page
|
||||
);
|
||||
video_url = video_url.replace(" ", "+");
|
||||
match self
|
||||
.stars
|
||||
.read()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|s| s.title.to_ascii_lowercase() == search_string)
|
||||
{
|
||||
Some(star) => {
|
||||
sort_string = match options.sort.as_ref().unwrap().as_str() {
|
||||
"mostviewed.html" => "views/?page=".to_string(),
|
||||
"longest.html" => "duration/?page=".to_string(),
|
||||
"newest.html" => "?page=".to_string(),
|
||||
_ => "rating/?page=".to_string(),
|
||||
};
|
||||
video_url = format!("{}/{}{}{}", self.url, star.id, sort_string, page);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
match self
|
||||
.sites
|
||||
.read()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|s| s.title.to_ascii_lowercase() == search_string)
|
||||
{
|
||||
Some(site) => {
|
||||
sort_string = match options.sort.as_ref().unwrap().as_str() {
|
||||
"mostviewed.html" => "?orderBy=mv&page=".to_string(),
|
||||
"longest.html" => "?orderBy=ln&page=".to_string(),
|
||||
"newest.html" => "?page=".to_string(),
|
||||
_ => "?orderBy=tr&page=".to_string(),
|
||||
};
|
||||
video_url = format!("{}/{}{}{}", self.url, site.id, 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 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();
|
||||
if !html.contains("video-box ") {
|
||||
return items;
|
||||
}
|
||||
let raw_videos = html.split("id=\"pagination\"").collect::<Vec<&str>>()[0]
|
||||
.split("-thumbs")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("\"video-box ")
|
||||
.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("adsbytrafficjunky"){
|
||||
continue;
|
||||
}
|
||||
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("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 = match video_segment.split("thumb-image ").collect::<Vec<&str>>()[1]
|
||||
.contains("data-src=\"")
|
||||
{
|
||||
true => video_segment.split("thumb-image ").collect::<Vec<&str>>()[1]
|
||||
.split("data-src=\"")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string(),
|
||||
false => video_segment.split("thumb-image ").collect::<Vec<&str>>()[1]
|
||||
.split("data-poster=\"")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string(),
|
||||
};
|
||||
let raw_duration = video_segment
|
||||
.split("video-duration ")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("</span>")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.split("<span>")
|
||||
.collect::<Vec<&str>>()
|
||||
.last()
|
||||
.unwrap_or(&"")
|
||||
.replace("\n", "")
|
||||
.trim()
|
||||
.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("<span class='info-views'>")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string()
|
||||
.as_str(),
|
||||
)
|
||||
.unwrap_or(0) as u32;
|
||||
let mut tags = match video_segment.contains("info-views-container block") {
|
||||
true => video_segment
|
||||
.split("info-views-container block")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("view-rating-container")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.split("<a ")
|
||||
.collect::<Vec<&str>>()[1..]
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
let mut target = &self.stars;
|
||||
if s.contains("author-title-text "){
|
||||
target = &self.sites
|
||||
}
|
||||
let id = s.split("href=\"")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0];
|
||||
let title = s.split(">")
|
||||
.collect::<Vec<&str>>()[1]
|
||||
.split("</a")
|
||||
.collect::<Vec<&str>>()[0];
|
||||
Self::push_unique(
|
||||
target,
|
||||
FilterOption {
|
||||
id: id.to_string(),
|
||||
title: title.to_string(),
|
||||
},
|
||||
);
|
||||
title.to_string()
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
.to_vec(),
|
||||
false => vec![],
|
||||
};
|
||||
let video_item = VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
video_url.to_string(),
|
||||
"tube8".to_string(),
|
||||
thumb,
|
||||
duration,
|
||||
)
|
||||
.views(views)
|
||||
.tags(tags);
|
||||
items.push(video_item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for Tube8Provider {
|
||||
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))
|
||||
}
|
||||
}
|
||||
355
build.rs
Normal file
@@ -0,0 +1,355 @@
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
struct ProviderDef {
|
||||
id: &'static str,
|
||||
module: &'static str,
|
||||
ty: &'static str,
|
||||
}
|
||||
|
||||
const PROVIDERS: &[ProviderDef] = &[
|
||||
ProviderDef {
|
||||
id: "all",
|
||||
module: "all",
|
||||
ty: "AllProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "perverzija",
|
||||
module: "perverzija",
|
||||
ty: "PerverzijaProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "hanime",
|
||||
module: "hanime",
|
||||
ty: "HanimeProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "pornhub",
|
||||
module: "pornhub",
|
||||
ty: "PornhubProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "pornhd3x",
|
||||
module: "pornhd3x",
|
||||
ty: "Pornhd3xProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "spankbang",
|
||||
module: "spankbang",
|
||||
ty: "SpankbangProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "rule34video",
|
||||
module: "rule34video",
|
||||
ty: "Rule34videoProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "redtube",
|
||||
module: "redtube",
|
||||
ty: "RedtubeProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "okporn",
|
||||
module: "okporn",
|
||||
ty: "OkpornProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "pornhat",
|
||||
module: "pornhat",
|
||||
ty: "PornhatProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "perfectgirls",
|
||||
module: "perfectgirls",
|
||||
ty: "PerfectgirlsProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "okxxx",
|
||||
module: "okxxx",
|
||||
ty: "OkxxxProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "homoxxx",
|
||||
module: "homoxxx",
|
||||
ty: "HomoxxxProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "missav",
|
||||
module: "missav",
|
||||
ty: "MissavProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "xxthots",
|
||||
module: "xxthots",
|
||||
ty: "XxthotsProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "yesporn",
|
||||
module: "yesporn",
|
||||
ty: "YespornProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "sxyprn",
|
||||
module: "sxyprn",
|
||||
ty: "SxyprnProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "porn00",
|
||||
module: "porn00",
|
||||
ty: "Porn00Provider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "youjizz",
|
||||
module: "youjizz",
|
||||
ty: "YoujizzProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "paradisehill",
|
||||
module: "paradisehill",
|
||||
ty: "ParadisehillProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "porn4fans",
|
||||
module: "porn4fans",
|
||||
ty: "Porn4fansProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "pornmz",
|
||||
module: "pornmz",
|
||||
ty: "PornmzProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "porndish",
|
||||
module: "porndish",
|
||||
ty: "PorndishProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "shooshtime",
|
||||
module: "shooshtime",
|
||||
ty: "ShooshtimeProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "pornzog",
|
||||
module: "pornzog",
|
||||
ty: "PornzogProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "omgxxx",
|
||||
module: "omgxxx",
|
||||
ty: "OmgxxxProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "beeg",
|
||||
module: "beeg",
|
||||
ty: "BeegProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "tnaflix",
|
||||
module: "tnaflix",
|
||||
ty: "TnaflixProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "tokyomotion",
|
||||
module: "tokyomotion",
|
||||
ty: "TokyomotionProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "viralxxxporn",
|
||||
module: "viralxxxporn",
|
||||
ty: "ViralxxxpornProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "vrporn",
|
||||
module: "vrporn",
|
||||
ty: "VrpornProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "rule34gen",
|
||||
module: "rule34gen",
|
||||
ty: "Rule34genProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "xxdbx",
|
||||
module: "xxdbx",
|
||||
ty: "XxdbxProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "xfree",
|
||||
module: "xfree",
|
||||
ty: "XfreeProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "hqporner",
|
||||
module: "hqporner",
|
||||
ty: "HqpornerProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "pmvhaven",
|
||||
module: "pmvhaven",
|
||||
ty: "PmvhavenProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "noodlemagazine",
|
||||
module: "noodlemagazine",
|
||||
ty: "NoodlemagazineProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "pimpbunny",
|
||||
module: "pimpbunny",
|
||||
ty: "PimpbunnyProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "javtiful",
|
||||
module: "javtiful",
|
||||
ty: "JavtifulProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "hypnotube",
|
||||
module: "hypnotube",
|
||||
ty: "HypnotubeProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "freepornvideosxxx",
|
||||
module: "freepornvideosxxx",
|
||||
ty: "FreepornvideosxxxProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "freeuseporn",
|
||||
module: "freeuseporn",
|
||||
ty: "FreeusepornProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "heavyfetish",
|
||||
module: "heavyfetish",
|
||||
ty: "HeavyfetishProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "hsex",
|
||||
module: "hsex",
|
||||
ty: "HsexProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "sextb",
|
||||
module: "sextb",
|
||||
ty: "SextbProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "hentaihaven",
|
||||
module: "hentaihaven",
|
||||
ty: "HentaihavenProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "chaturbate",
|
||||
module: "chaturbate",
|
||||
ty: "ChaturbateProvider",
|
||||
},
|
||||
];
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
println!("cargo:rerun-if-env-changed=HOT_TUB_PROVIDER");
|
||||
println!("cargo:rerun-if-env-changed=HOTTUB_PROVIDER");
|
||||
println!("cargo:rustc-check-cfg=cfg(hottub_single_provider)");
|
||||
let provider_cfg_values = PROVIDERS
|
||||
.iter()
|
||||
.map(|provider| format!("\"{}\"", provider.id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
println!("cargo:rustc-check-cfg=cfg(hottub_provider, values({provider_cfg_values}))");
|
||||
|
||||
let selected = env::var("HOT_TUB_PROVIDER")
|
||||
.or_else(|_| env::var("HOTTUB_PROVIDER"))
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
|
||||
let providers = match selected.as_deref() {
|
||||
Some(selected_id) => {
|
||||
let provider = PROVIDERS
|
||||
.iter()
|
||||
.find(|provider| provider.id == selected_id)
|
||||
.unwrap_or_else(|| {
|
||||
panic!("Unknown provider `{selected_id}` from HOT_TUB_PROVIDER/HOTTUB_PROVIDER")
|
||||
});
|
||||
println!("cargo:rustc-cfg=hottub_single_provider");
|
||||
println!("cargo:rustc-cfg=hottub_provider=\"{selected_id}\"");
|
||||
vec![provider]
|
||||
}
|
||||
None => PROVIDERS.iter().collect(),
|
||||
};
|
||||
|
||||
let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR"));
|
||||
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));
|
||||
|
||||
let modules = providers
|
||||
.iter()
|
||||
.map(|provider| {
|
||||
let module_path = manifest_dir
|
||||
.join("src/providers")
|
||||
.join(format!("{}.rs", provider.module));
|
||||
format!(
|
||||
"#[path = r#\"{}\"#]\npub mod {};",
|
||||
module_path.display(),
|
||||
provider.module
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
fs::write(out_dir.join("provider_modules.rs"), format!("{modules}\n"))
|
||||
.expect("write provider_modules.rs");
|
||||
|
||||
let registry = providers
|
||||
.iter()
|
||||
.map(|provider| {
|
||||
format!(
|
||||
"m.insert(\"{id}\", Arc::new({module}::{ty}::new()) as DynProvider);",
|
||||
id = provider.id,
|
||||
module = provider.module,
|
||||
ty = provider.ty
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
fs::write(
|
||||
out_dir.join("provider_registry.rs"),
|
||||
format!("{{\n{registry}\n}}\n"),
|
||||
)
|
||||
.expect("write provider_registry.rs");
|
||||
|
||||
let metadata_arms = providers
|
||||
.iter()
|
||||
.map(|provider| {
|
||||
if provider.id == "all" {
|
||||
format!(
|
||||
"\"all\" | \"hottub\" => Some({module}::CHANNEL_METADATA),",
|
||||
module = provider.module
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"\"{id}\" => Some({module}::CHANNEL_METADATA),",
|
||||
id = provider.id,
|
||||
module = provider.module
|
||||
)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
fs::write(
|
||||
out_dir.join("provider_metadata_fn.rs"),
|
||||
format!("match id {{\n{metadata_arms}\n_ => None,\n}}\n"),
|
||||
)
|
||||
.expect("write provider_metadata_fn.rs");
|
||||
|
||||
let selection = match selected.as_deref() {
|
||||
Some(selected_id) => format!(
|
||||
"pub const COMPILE_TIME_SELECTED_PROVIDER: Option<&str> = Some(\"{selected_id}\");"
|
||||
),
|
||||
None => "pub const COMPILE_TIME_SELECTED_PROVIDER: Option<&str> = None;".to_string(),
|
||||
};
|
||||
fs::write(
|
||||
out_dir.join("provider_selection.rs"),
|
||||
format!("{selection}\n"),
|
||||
)
|
||||
.expect("write provider_selection.rs");
|
||||
}
|
||||
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
|
||||
|
||||
|
||||
60
docs/uploaders-endpoint-plan.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Uploaders Endpoint Plan
|
||||
|
||||
## Summary
|
||||
|
||||
Implement `POST /api/uploaders` using the Hot Tub uploader profile contract and ship it framework-first. The server will expose shared uploader request/response types, a provider hook for uploader lookup, endpoint routing in `src/api.rs`, and a first real provider implementation in `hsex`.
|
||||
|
||||
## Implementation
|
||||
|
||||
- Add dedicated uploader API types in `src/uploaders.rs`:
|
||||
- `UploadersRequest`
|
||||
- `UploaderProfile`
|
||||
- `UploaderChannelStat`
|
||||
- `UploaderVideoRef`
|
||||
- `UploaderLayoutRow`
|
||||
- Keep camelCase as the canonical serialized shape.
|
||||
- Accept documented decode aliases:
|
||||
- `uploader_id`
|
||||
- `uploader_name`
|
||||
- `profile_content`
|
||||
- `profile_picture_url`
|
||||
- `video_ids`
|
||||
- `horizontal_videos`
|
||||
- Add `POST /api/uploaders` in `src/api.rs`.
|
||||
- Validate that at least one of `uploaderId` or `uploaderName` is present.
|
||||
- Return:
|
||||
- `400` for invalid request
|
||||
- `404` for no match
|
||||
- `500` for provider execution failure
|
||||
- Add `Provider::get_uploader(...)` with a default `Ok(None)` implementation.
|
||||
- Add a guarded uploader execution helper in `src/providers/mod.rs`.
|
||||
- Use canonical uploader IDs in the format `<channel>:<provider-local-id>`.
|
||||
- Implement the first provider-backed uploader profile in `src/providers/hsex.rs`.
|
||||
|
||||
## Hsex Strategy
|
||||
|
||||
- Resolve uploader lookup by canonical uploader ID or exact uploader name.
|
||||
- Reuse existing uploader archive discovery and archive page fetching.
|
||||
- Build uploader profile metadata from uploader archive pages.
|
||||
- Populate `videos` with `UploaderVideoRef` values derived from existing `VideoItem`s.
|
||||
- Always return `layout`.
|
||||
- When `profileContent == true`, return:
|
||||
- `videos`
|
||||
- `tapes: []`
|
||||
- `playlists: []`
|
||||
- a `"For You"` horizontal row plus the default videos row
|
||||
- When `profileContent == false`, return metadata and layout only.
|
||||
|
||||
## Tests
|
||||
|
||||
- Request alias decoding for uploader request fields.
|
||||
- Response alias decoding for avatar and layout row compatibility fields.
|
||||
- Endpoint helper tests for request validation and provider routing.
|
||||
- Hsex uploader ID generation and uploader page parsing coverage.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The first ship focuses on the endpoint framework and one real provider implementation.
|
||||
- Providers without explicit uploader support remain unsupported by `/api/uploaders`.
|
||||
- Name-based resolution uses exact display-name matching.
|
||||
- `videoCount` and `totalViews` are best-effort when the upstream site does not expose authoritative profile totals.
|
||||
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-->
|
||||
1210
src/api.rs
91
src/db.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
use diesel::prelude::*;
|
||||
|
||||
#[cfg(any(
|
||||
not(hottub_single_provider),
|
||||
hottub_provider = "hanime",
|
||||
hottub_provider = "hentaihaven",
|
||||
hottub_provider = "missav",
|
||||
hottub_provider = "perverzija",
|
||||
))]
|
||||
pub fn get_video(
|
||||
conn: &mut SqliteConnection,
|
||||
video_id: String,
|
||||
) -> Result<Option<String>, diesel::result::Error> {
|
||||
use crate::models::DBVideo;
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(
|
||||
not(hottub_single_provider),
|
||||
hottub_provider = "hanime",
|
||||
hottub_provider = "hentaihaven",
|
||||
hottub_provider = "missav",
|
||||
hottub_provider = "perverzija",
|
||||
))]
|
||||
pub fn insert_video(
|
||||
conn: &mut SqliteConnection,
|
||||
new_id: &str,
|
||||
new_url: &str,
|
||||
) -> Result<usize, diesel::result::Error> {
|
||||
use crate::models::DBVideo;
|
||||
use crate::schema::videos::dsl::*;
|
||||
diesel::insert_into(videos)
|
||||
.values(DBVideo {
|
||||
id: new_id.to_string(),
|
||||
url: new_url.to_string(),
|
||||
})
|
||||
.execute(conn)
|
||||
}
|
||||
|
||||
#[cfg(any(
|
||||
not(hottub_single_provider),
|
||||
hottub_provider = "hanime",
|
||||
hottub_provider = "hentaihaven",
|
||||
hottub_provider = "missav",
|
||||
hottub_provider = "perverzija",
|
||||
))]
|
||||
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(())
|
||||
}
|
||||
106
src/main.rs
@@ -1,30 +1,112 @@
|
||||
#![warn(unused_extern_crates)]
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use std::{env, thread};
|
||||
|
||||
use diesel::{
|
||||
SqliteConnection,
|
||||
r2d2::{self, ConnectionManager},
|
||||
};
|
||||
use dotenvy::dotenv;
|
||||
use ntex::web;
|
||||
use ntex_files as fs;
|
||||
|
||||
use ntex::web;
|
||||
use ntex::web::HttpResponse;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
mod api;
|
||||
mod status;
|
||||
mod videos;
|
||||
mod db;
|
||||
mod models;
|
||||
mod providers;
|
||||
mod proxies;
|
||||
mod proxy;
|
||||
mod schema;
|
||||
mod status;
|
||||
mod uploaders;
|
||||
mod util;
|
||||
mod videos;
|
||||
|
||||
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_LOG", "ntex=warn");
|
||||
std::env::set_var("RUST_BACKTRACE", "1");
|
||||
// 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
|
||||
crate::flow_debug!(
|
||||
"startup begin rust_log={} debug_compiled={}",
|
||||
std::env::var("RUST_LOG").unwrap_or_else(|_| "unset".to_string()),
|
||||
cfg!(feature = "debug")
|
||||
);
|
||||
|
||||
// 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.");
|
||||
crate::flow_debug!(
|
||||
"database pool ready database_url={}",
|
||||
crate::util::flow_debug::preview(&connspec, 96)
|
||||
);
|
||||
|
||||
web::HttpServer::new(|| {
|
||||
let mut requester = util::requester::Requester::new();
|
||||
let proxy_enabled = env::var("PROXY").unwrap_or("0".to_string()) != "0".to_string();
|
||||
requester.set_proxy(proxy_enabled);
|
||||
crate::flow_debug!("requester initialized proxy_enabled={}", proxy_enabled);
|
||||
|
||||
let cache: util::cache::VideoCache = crate::util::cache::VideoCache::new()
|
||||
.max_size(100_000)
|
||||
.to_owned();
|
||||
crate::flow_debug!("video cache initialized max_size=100000");
|
||||
let _ = providers::configure_runtime_validation(pool.clone(), cache.clone(), requester.clone());
|
||||
|
||||
thread::spawn(move || {
|
||||
crate::flow_debug!("provider init thread spawned");
|
||||
// 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 {
|
||||
crate::flow_debug!("provider init begin");
|
||||
providers::init_providers_now();
|
||||
crate::flow_debug!("provider init complete");
|
||||
});
|
||||
});
|
||||
|
||||
crate::flow_debug!("http server binding addr=0.0.0.0:18080 workers=8");
|
||||
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(fs::Files::new("/", "static"))
|
||||
.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()
|
||||
|
||||
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,
|
||||
}
|
||||
182
src/providers/all.rs
Normal file
@@ -0,0 +1,182 @@
|
||||
use crate::DbPool;
|
||||
use crate::api::{ClientVersion, get_provider};
|
||||
use crate::providers::{DynProvider, Provider, report_provider_error, run_provider_guarded};
|
||||
use crate::status::{Channel, ChannelOption, FilterOption};
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::interleave;
|
||||
use crate::videos::{ServerOptions, VideoItem};
|
||||
use async_trait::async_trait;
|
||||
use capitalize::Capitalize;
|
||||
use cute::c;
|
||||
use error_chain::error_chain;
|
||||
use futures::StreamExt;
|
||||
use futures::stream::FuturesUnordered;
|
||||
use std::fs;
|
||||
use std::time::Duration;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "meta-search",
|
||||
tags: &["aggregator", "multi-site", "search"],
|
||||
};
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
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 = match fs::read_dir("./src/providers") {
|
||||
Ok(files) => files,
|
||||
Err(e) => {
|
||||
report_provider_error("all", "all.get_videos.read_dir", &e.to_string()).await;
|
||||
return vec![];
|
||||
}
|
||||
};
|
||||
let providers = files
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter_map(|entry| entry.file_name().into_string().ok())
|
||||
.filter(|name| name.ends_with(".rs"))
|
||||
.filter(|name| !name.contains("mod.rs") && !name.contains("all.rs"))
|
||||
.map(|name| name.replace(".rs", ""))
|
||||
.collect::<Vec<String>>();
|
||||
sites_str = providers.join(",");
|
||||
}
|
||||
|
||||
let providers: Vec<(String, DynProvider)> = sites_str
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter_map(|s| {
|
||||
let provider = get_provider(s);
|
||||
if provider.is_none() {
|
||||
Some((s.to_string(), None))
|
||||
} else {
|
||||
provider.map(|p| (s.to_string(), Some(p)))
|
||||
}
|
||||
})
|
||||
.filter_map(|(name, provider)| match provider {
|
||||
Some(provider) => Some((name, provider)),
|
||||
None => {
|
||||
// fire-and-forget reporting of missing provider keys
|
||||
tokio::spawn(async move {
|
||||
report_provider_error("all", "all.get_videos.unknown_provider", &name)
|
||||
.await;
|
||||
});
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut futures = FuturesUnordered::new();
|
||||
|
||||
for (provider_name, 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();
|
||||
let provider_name_cloned = provider_name.clone();
|
||||
|
||||
// Spawn the task so it lives independently of this function
|
||||
futures.push(tokio::spawn(async move {
|
||||
run_provider_guarded(
|
||||
&provider_name_cloned,
|
||||
"all.get_videos.provider_task",
|
||||
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() => {
|
||||
match result {
|
||||
Ok(videos) => all_results.push(videos),
|
||||
Err(e) => {
|
||||
report_provider_error("all", "all.get_videos.join_error", &e.to_string()).await;
|
||||
}
|
||||
}
|
||||
},
|
||||
_ = &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;
|
||||
let files = fs::read_dir("./src/providers").ok()?;
|
||||
let providers = files
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter_map(|entry| entry.file_name().into_string().ok())
|
||||
.filter(|name| name.ends_with(".rs"))
|
||||
.filter(|name| !name.contains("mod.rs") && !name.contains("all.rs"))
|
||||
.map(|name| name.replace(".rs", ""))
|
||||
.collect::<Vec<String>>();
|
||||
let sites = c![FilterOption {
|
||||
id: x.to_string(),
|
||||
title: x.capitalize().to_string(),
|
||||
}, for x in providers.iter()];
|
||||
|
||||
Some(Channel {
|
||||
id: "all".to_string(),
|
||||
name: "All".to_string(),
|
||||
description: "Query from all sites of this Server".to_string(),
|
||||
premium: false,
|
||||
favicon: "/favicon.ico".to_string(),
|
||||
status: "active".to_string(),
|
||||
categories: vec![],
|
||||
options: vec![ChannelOption {
|
||||
id: "all_provider_sites".to_string(),
|
||||
title: "Sites".to_string(),
|
||||
description: "What Sites to use".to_string(),
|
||||
systemImage: "list.number".to_string(),
|
||||
colorName: "green".to_string(),
|
||||
options: sites,
|
||||
multiSelect: true,
|
||||
}],
|
||||
nsfw: true,
|
||||
cacheDuration: Some(1800),
|
||||
})
|
||||
}
|
||||
}
|
||||
466
src/providers/beeg.rs
Normal file
@@ -0,0 +1,466 @@
|
||||
use crate::DbPool;
|
||||
use crate::api::ClientVersion;
|
||||
use crate::providers::{Provider, report_provider_error, report_provider_error_background};
|
||||
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::time::Duration;
|
||||
use std::vec;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "mainstream-tube",
|
||||
tags: &["mainstream", "clips", "mixed"],
|
||||
};
|
||||
|
||||
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 {
|
||||
match Self::fetch_tags().await {
|
||||
Ok(json) => {
|
||||
Self::load_sites(&json, sites);
|
||||
Self::load_categories(&json, categories);
|
||||
Self::load_stars(&json, stars);
|
||||
}
|
||||
Err(e) => {
|
||||
report_provider_error("beeg", "init.fetch_tags", &e.to_string()).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async fn fetch_tags() -> Result<Value> {
|
||||
let mut requester = util::requester::Requester::new();
|
||||
let endpoints = [
|
||||
"https://store.externulls.com/tag/facts/tags?get_original=true&slug=index",
|
||||
"https://store.externulls.com/tag/facts/tags?slug=index",
|
||||
];
|
||||
let mut errors: Vec<String> = vec![];
|
||||
|
||||
for endpoint in endpoints {
|
||||
for attempt in 1..=3 {
|
||||
match requester.get(endpoint, None).await {
|
||||
Ok(text) => match serde_json::from_str::<Value>(&text) {
|
||||
Ok(json) => return Ok(json),
|
||||
Err(e) => {
|
||||
errors
|
||||
.push(format!("endpoint={endpoint}; attempt={attempt}; parse={e}"));
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
errors.push(format!(
|
||||
"endpoint={endpoint}; attempt={attempt}; request={e}"
|
||||
));
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(250 * attempt as u64)).await;
|
||||
}
|
||||
}
|
||||
|
||||
Err(ErrorKind::Parse(format!("failed to fetch tags; {}", errors.join(" | "))).into())
|
||||
}
|
||||
|
||||
fn load_stars(json: &Value, stars: Arc<RwLock<Vec<FilterOption>>>) {
|
||||
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(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_categories(json: &Value, categories: Arc<RwLock<Vec<FilterOption>>>) {
|
||||
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('}', ""),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_sites(json: &Value, sites: Arc<RwLock<Vec<FilterOption>>>) {
|
||||
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(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 let Some(categories) = options.categories.as_ref() {
|
||||
if !categories.is_empty() && categories != "all" {
|
||||
slug = categories;
|
||||
}
|
||||
}
|
||||
if let Some(sites) = options.sites.as_ref() {
|
||||
if !sites.is_empty() && sites != "all" {
|
||||
slug = sites;
|
||||
}
|
||||
}
|
||||
if let Some(stars) = options.stars.as_ref() {
|
||||
if !stars.is_empty() && stars != "all" {
|
||||
slug = stars;
|
||||
}
|
||||
}
|
||||
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 =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
let text = match requester.get(&video_url, None).await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
report_provider_error_background("beeg", "get.request", &e.to_string());
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
let json: serde_json::Value = match serde_json::from_str::<serde_json::Value>(&text) {
|
||||
Ok(json) => json,
|
||||
Err(e) => {
|
||||
report_provider_error_background("beeg", "get.parse_json", &e.to_string());
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
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 =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
|
||||
let text = match requester.get(&video_url, None).await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
report_provider_error_background("beeg", "query.request", &e.to_string());
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
let json: serde_json::Value = match serde_json::from_str::<serde_json::Value>(&text) {
|
||||
Ok(json) => json,
|
||||
Err(e) => {
|
||||
report_provider_error_background("beeg", "query.parse_json", &e.to_string());
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
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))
|
||||
}
|
||||
}
|
||||
348
src/providers/chaturbate.rs
Normal file
@@ -0,0 +1,348 @@
|
||||
use crate::DbPool;
|
||||
use crate::api::ClientVersion;
|
||||
use crate::providers::{Provider, report_provider_error};
|
||||
use crate::status::*;
|
||||
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};
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "live-cams",
|
||||
tags: &["live", "cams", "amateur"],
|
||||
};
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChaturbateProvider {
|
||||
url: String,
|
||||
}
|
||||
impl ChaturbateProvider {
|
||||
pub fn new() -> Self {
|
||||
let provider = ChaturbateProvider {
|
||||
url: "https://chaturbate.com".to_string(),
|
||||
};
|
||||
provider
|
||||
}
|
||||
|
||||
fn build_channel(&self, clientversion: ClientVersion) -> Channel {
|
||||
let _ = clientversion;
|
||||
|
||||
Channel {
|
||||
id: "chaturbate".to_string(),
|
||||
name: "WORK IN PROGRESS Chaturbate".to_string(),
|
||||
description: "Free Adult Webcams".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=chaturbate.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: "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,
|
||||
}],
|
||||
nsfw: true,
|
||||
cacheDuration: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let video_url = format!(
|
||||
"{}/api/ts/roomlist/room-list/?limit=90&offset={}",
|
||||
self.url,
|
||||
90 * (page - 1)
|
||||
);
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 1 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
items.clone()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let mut requester =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
let response = match requester
|
||||
.get_raw_with_headers(
|
||||
&video_url,
|
||||
vec![("X-Requested-With".to_string(), "XMLHttpRequest".to_string())],
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(e) => {
|
||||
report_provider_error(
|
||||
"chaturbate",
|
||||
"get.request",
|
||||
&format!("url={video_url}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
let text = match response.text().await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
report_provider_error(
|
||||
"chaturbate",
|
||||
"get.response_text",
|
||||
&format!("url={video_url}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
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 video_url = format!(
|
||||
"{}/api/ts/roomlist/room-list/?keywords={}&limit=90&offset={}",
|
||||
query,
|
||||
self.url,
|
||||
90 * (page - 1)
|
||||
);
|
||||
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 * 1 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
let _ = cache.check().await;
|
||||
return Ok(items.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let mut requester =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
let response = match requester
|
||||
.get_raw_with_headers(
|
||||
&video_url,
|
||||
vec![("X-Requested-With".to_string(), "XMLHttpRequest".to_string())],
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(e) => {
|
||||
report_provider_error(
|
||||
"chaturbate",
|
||||
"query.request",
|
||||
&format!("url={video_url}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
let text = match response.text().await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
report_provider_error(
|
||||
"chaturbate",
|
||||
"query.response_text",
|
||||
&format!("url={video_url}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
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::new();
|
||||
let json = serde_json::from_str::<serde_json::Value>(html.as_str()).unwrap_or_else(|e| {
|
||||
println!("Failed to parse JSON: {}", e);
|
||||
serde_json::Value::Null
|
||||
});
|
||||
let rooms = match json.get("rooms").and_then(|v| v.as_array()) {
|
||||
Some(rooms) => rooms,
|
||||
None => {
|
||||
crate::providers::report_provider_error_background(
|
||||
"chaturbate",
|
||||
"get_video_items_from_html.rooms_missing",
|
||||
"missing rooms array",
|
||||
);
|
||||
return items;
|
||||
}
|
||||
};
|
||||
for video_segment in rooms {
|
||||
if video_segment
|
||||
.get("has_password")
|
||||
.unwrap_or(&serde_json::Value::Bool(false))
|
||||
.as_bool()
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// let vid = video_segment.split("\n").collect::<Vec<&str>>();
|
||||
// for (index, line) in vid.iter().enumerate() {
|
||||
// println!("Line {}: {}", index, line);
|
||||
// }
|
||||
let Some(username) = video_segment
|
||||
.get("username")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
else {
|
||||
crate::providers::report_provider_error_background(
|
||||
"chaturbate",
|
||||
"get_video_items_from_html.username_missing",
|
||||
"missing username field",
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let video_url: String = format!("{}/{}/", self.url, username);
|
||||
let mut title = video_segment
|
||||
.get("room_subject")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or("".to_string());
|
||||
// html decode
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
let id = username.clone();
|
||||
|
||||
let thumb = video_segment
|
||||
.get("img")
|
||||
.unwrap_or(&serde_json::Value::String("".to_string()))
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.split("?")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let views = video_segment
|
||||
.get("viewers")
|
||||
.unwrap_or(&serde_json::Value::Number(serde_json::Number::from(0)))
|
||||
.as_u64()
|
||||
.unwrap_or(0);
|
||||
|
||||
let tags = video_segment
|
||||
.get("tags")
|
||||
.unwrap_or(&serde_json::Value::Array(vec![]))
|
||||
.as_array()
|
||||
.unwrap_or(&vec![])
|
||||
.iter()
|
||||
.filter_map(|t| t.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
let video_item = VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
video_url.to_string(),
|
||||
"chaturbate".to_string(),
|
||||
thumb,
|
||||
0,
|
||||
)
|
||||
.is_live(true)
|
||||
.views(views as u32)
|
||||
.uploader(username.clone())
|
||||
.uploader_url(video_url.clone())
|
||||
.tags(tags);
|
||||
items.push(video_item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for ChaturbateProvider {
|
||||
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), 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))
|
||||
}
|
||||
}
|
||||
890
src/providers/freepornvideosxxx.rs
Normal file
@@ -0,0 +1,890 @@
|
||||
use crate::DbPool;
|
||||
use crate::api::ClientVersion;
|
||||
use crate::providers::{Provider, report_provider_error, report_provider_error_background};
|
||||
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;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "studio-network",
|
||||
tags: &["tube", "networked", "mixed"],
|
||||
};
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FreepornvideosxxxProvider {
|
||||
url: String,
|
||||
sites: Arc<RwLock<Vec<FilterOption>>>,
|
||||
networks: Arc<RwLock<Vec<FilterOption>>>,
|
||||
stars: Arc<RwLock<Vec<FilterOption>>>,
|
||||
}
|
||||
impl FreepornvideosxxxProvider {
|
||||
pub fn new() -> Self {
|
||||
let provider = FreepornvideosxxxProvider {
|
||||
url: "https://www.freepornvideos.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 = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
report_provider_error_background(
|
||||
"freepornvideosxxx",
|
||||
"spawn_initial_load.runtime_build",
|
||||
&e.to_string(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
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 = match requester
|
||||
.get(
|
||||
format!("{}/models/total-videos/{}/?gender_id=0", &base_url, page).as_str(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
report_provider_error_background(
|
||||
"freepornvideosxxx",
|
||||
"load_stars.request",
|
||||
&format!("url={base_url}; page={page}; error={e}"),
|
||||
);
|
||||
break;
|
||||
}
|
||||
};
|
||||
if text.contains("404 Not Found") || text.is_empty() {
|
||||
break;
|
||||
}
|
||||
let stars_div = text
|
||||
.split("<div class=\"list-models\">")
|
||||
.collect::<Vec<&str>>()
|
||||
.last()
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("custom_list_models_models_list_pagination")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
for stars_element in stars_div.split("<a ").collect::<Vec<&str>>()[1..].to_vec() {
|
||||
let star_url = stars_element
|
||||
.split("href=\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
let star_id = star_url
|
||||
.split("/")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(4)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let star_name = stars_element
|
||||
.split("<strong class=\"title\">")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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;
|
||||
let text = match text {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
report_provider_error_background(
|
||||
"freepornvideosxxx",
|
||||
"load_sites.request",
|
||||
&format!("url={base_url}; page={page}; error={e}"),
|
||||
);
|
||||
break;
|
||||
}
|
||||
};
|
||||
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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("class=\"pagination\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
let site_id = site_url
|
||||
.split("/")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(4)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let site_name = sites_element
|
||||
.split("<h2>")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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 = match requester.get(&base_url, None).await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
report_provider_error_background(
|
||||
"freepornvideosxxx",
|
||||
"load_networks.request",
|
||||
&format!("url={base_url}; error={e}"),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let networks_div = text
|
||||
.split("class=\"sites__list\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("</div>")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
let network_id = network_url
|
||||
.split("/")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(4)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let network_name = network_element
|
||||
.split(">")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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: "freepornvideosxxx".to_string(),
|
||||
name: "FreePornVideos XXX".to_string(),
|
||||
description: "Free Porn Videos".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=www.freepornvideos.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 let Some(network) = options.network.as_deref() {
|
||||
if !network.is_empty() && network != "all" {
|
||||
sort_string = format!("networks/{}{}", network, alt_sort_string);
|
||||
}
|
||||
}
|
||||
if let Some(site) = options.sites.as_deref() {
|
||||
if !site.is_empty() && site != "all" {
|
||||
sort_string = format!("sites/{}{}", site, alt_sort_string);
|
||||
}
|
||||
}
|
||||
if let Some(star) = options.stars.as_deref() {
|
||||
if !star.is_empty() && star != "all" {
|
||||
sort_string = format!("models/{}{}", star, 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 =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
let text = match requester.get(&video_url, None).await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
report_provider_error(
|
||||
"freepornvideosxxx",
|
||||
"get.request",
|
||||
&format!("url={video_url}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
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() {
|
||||
Ok(stars) => {
|
||||
if let Some(star) = stars
|
||||
.iter()
|
||||
.find(|s| s.title.to_ascii_lowercase() == search_string)
|
||||
{
|
||||
search_type = "models";
|
||||
search_string = star.id.clone();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
report_provider_error_background(
|
||||
"freepornvideosxxx",
|
||||
"query.stars_read",
|
||||
&e.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
match self.sites.read() {
|
||||
Ok(sites) => {
|
||||
if let Some(site) = sites
|
||||
.iter()
|
||||
.find(|s| s.title.to_ascii_lowercase() == search_string)
|
||||
{
|
||||
search_type = "sites";
|
||||
search_string = site.id.clone();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
report_provider_error_background(
|
||||
"freepornvideosxxx",
|
||||
"query.sites_read",
|
||||
&e.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
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 =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
let text = match requester.get(&video_url, None).await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
report_provider_error(
|
||||
"freepornvideosxxx",
|
||||
"query.request",
|
||||
&format!("url={video_url}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
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(" ", "")
|
||||
let sites_guard = match self.sites.read() {
|
||||
Ok(guard) => guard,
|
||||
Err(e) => {
|
||||
report_provider_error_background(
|
||||
"freepornvideosxxx",
|
||||
"get_site_id_from_name.sites_read",
|
||||
&e.to_string(),
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
for site in sites_guard.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>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split(" class=\"pagination\" ")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("class=\"list-videos\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let mut title = video_segment
|
||||
.split(" title=\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
// html decode
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
let id = video_url
|
||||
.split("/")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(4)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
let thumb = match video_segment
|
||||
.split("<img ")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.contains("data-src=\"")
|
||||
{
|
||||
true => video_segment
|
||||
.split("<img ")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("data-src=\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
false => video_segment
|
||||
.split("<img ")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("src=\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
};
|
||||
let raw_duration = video_segment
|
||||
.split("<span class=\"duration\">")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
.as_str(),
|
||||
)
|
||||
.unwrap_or(0) as u32;
|
||||
|
||||
let preview = video_segment
|
||||
.split("data-preview=\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("</div>")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("href=\"")
|
||||
.collect::<Vec<&str>>()[1..]
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
Self::push_unique(
|
||||
&self.stars,
|
||||
FilterOption {
|
||||
id: s
|
||||
.split("/")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(4)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
title: s
|
||||
.split(">")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string(),
|
||||
},
|
||||
);
|
||||
s.split(">")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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(),
|
||||
"freepornvideosxxx".to_string(),
|
||||
thumb,
|
||||
duration,
|
||||
)
|
||||
.views(views)
|
||||
.preview(preview)
|
||||
.tags(tags);
|
||||
items.push(video_item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for FreepornvideosxxxProvider {
|
||||
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))
|
||||
}
|
||||
}
|
||||
611
src/providers/freeuseporn.rs
Normal file
@@ -0,0 +1,611 @@
|
||||
use crate::DbPool;
|
||||
use crate::api::ClientVersion;
|
||||
use crate::providers::{Provider, report_provider_error, requester_or_default};
|
||||
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, VideoFormat, VideoItem};
|
||||
use async_trait::async_trait;
|
||||
use error_chain::error_chain;
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
use scraper::{Html, Selector};
|
||||
use std::collections::HashSet;
|
||||
use std::vec;
|
||||
use url::form_urlencoded::Serializer;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "fetish-kink",
|
||||
tags: &["freeuse", "hypno", "mind-control"],
|
||||
};
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FreeusepornProvider {
|
||||
url: String,
|
||||
}
|
||||
|
||||
impl FreeusepornProvider {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
url: "https://www.freeuseporn.com".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
|
||||
Channel {
|
||||
id: "freeuseporn".to_string(),
|
||||
name: "FreeusePorn".to_string(),
|
||||
description: "FreeusePorn streams freeuse, hypno, mind control, ignored sex, and related fetish videos.".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=freeuseporn.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: "recent".to_string(),
|
||||
title: "Most Recent".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "viewed".to_string(),
|
||||
title: "Most Viewed".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "rated".to_string(),
|
||||
title: "Top Rated".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "favorites".to_string(),
|
||||
title: "Top Favorites".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "watched".to_string(),
|
||||
title: "Being Watched".to_string(),
|
||||
},
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
ChannelOption {
|
||||
id: "category".to_string(),
|
||||
title: "Category".to_string(),
|
||||
description: "Filter by category".to_string(),
|
||||
systemImage: "square.grid.2x2".to_string(),
|
||||
colorName: "orange".to_string(),
|
||||
options: vec![
|
||||
FilterOption {
|
||||
id: "all".to_string(),
|
||||
title: "All".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "mind-control".to_string(),
|
||||
title: "Mind Control".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "general-freeuse".to_string(),
|
||||
title: "General Freeuse".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "free-service".to_string(),
|
||||
title: "Free Service".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "forced".to_string(),
|
||||
title: "Forced".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "japanese".to_string(),
|
||||
title: "Japanese".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "time-stop".to_string(),
|
||||
title: "Time Stop".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "ignored-sex".to_string(),
|
||||
title: "Ignored Sex".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "glory-hole".to_string(),
|
||||
title: "Glory Hole".to_string(),
|
||||
},
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
nsfw: true,
|
||||
cacheDuration: Some(1800),
|
||||
}
|
||||
}
|
||||
|
||||
fn absolute_url(&self, url: &str) -> String {
|
||||
if url.starts_with("http://") || url.starts_with("https://") {
|
||||
url.to_string()
|
||||
} else if url.starts_with('/') {
|
||||
format!("{}{}", self.url, url)
|
||||
} else {
|
||||
format!("{}/{}", self.url, url.trim_start_matches('/'))
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_param(sort: &str) -> &'static str {
|
||||
match sort {
|
||||
"viewed" => "mv",
|
||||
"rated" => "tr",
|
||||
"favorites" => "tf",
|
||||
"watched" => "bw",
|
||||
_ => "mr",
|
||||
}
|
||||
}
|
||||
|
||||
fn append_sort_and_page(&self, base_url: &str, sort: &str, page: u8) -> String {
|
||||
let mut params = vec![format!("o={}", Self::sort_param(sort))];
|
||||
if page > 1 {
|
||||
params.push(format!("page={page}"));
|
||||
}
|
||||
|
||||
if params.is_empty() {
|
||||
return base_url.to_string();
|
||||
}
|
||||
|
||||
let separator = if base_url.contains('?') { "&" } else { "?" };
|
||||
format!("{base_url}{separator}{}", params.join("&"))
|
||||
}
|
||||
|
||||
fn build_list_url(&self, sort: &str, page: u8, category: Option<&str>) -> String {
|
||||
let path = if let Some(category) = category
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty() && *value != "all")
|
||||
{
|
||||
format!("/videos/{}", category)
|
||||
} else {
|
||||
"/videos".to_string()
|
||||
};
|
||||
|
||||
let base_url = format!("{}{}", self.url, path);
|
||||
self.append_sort_and_page(&base_url, sort, page)
|
||||
}
|
||||
|
||||
fn build_search_request_body(query: &str) -> String {
|
||||
let mut serializer = Serializer::new(String::new());
|
||||
serializer.append_pair("search_query", query);
|
||||
serializer.finish()
|
||||
}
|
||||
|
||||
async fn resolve_search_url(&self, query: &str, options: &ServerOptions) -> Result<String> {
|
||||
let search_url = format!("{}/search/videos", self.url);
|
||||
let search_body = Self::build_search_request_body(query);
|
||||
let referer = format!("{}/videos", self.url);
|
||||
let mut requester = requester_or_default(options, module_path!(), "missing_requester");
|
||||
let response = requester
|
||||
.post(
|
||||
&search_url,
|
||||
&search_body,
|
||||
vec![
|
||||
("Content-Type", "application/x-www-form-urlencoded"),
|
||||
("Referer", referer.as_str()),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.map_err(|error| format!("search submit failed url={search_url}; error={error}"))?;
|
||||
|
||||
Ok(response.uri().to_string().trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
fn build_formats(&self, id: &str) -> Vec<VideoFormat> {
|
||||
let hd = VideoFormat::new(
|
||||
format!("{}/media/videos/h264/{}_720p.mp4", self.url, id),
|
||||
"720p".to_string(),
|
||||
"video/mp4".to_string(),
|
||||
)
|
||||
.format_id("720p".to_string())
|
||||
.format_note("720p".to_string());
|
||||
let sd = VideoFormat::new(
|
||||
format!("{}/media/videos/h264/{}_480p.mp4", self.url, id),
|
||||
"480p".to_string(),
|
||||
"video/mp4".to_string(),
|
||||
)
|
||||
.format_id("480p".to_string())
|
||||
.format_note("480p".to_string());
|
||||
vec![hd, sd]
|
||||
}
|
||||
|
||||
fn normalized_text(text: &str) -> String {
|
||||
text.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
fn decode_text(value: &str) -> String {
|
||||
decode(value.as_bytes())
|
||||
.to_string()
|
||||
.unwrap_or_else(|_| value.to_string())
|
||||
}
|
||||
|
||||
fn parse_views(value: &str) -> Option<u32> {
|
||||
let digits = value
|
||||
.chars()
|
||||
.filter(|character| character.is_ascii_digit() || *character == '.' || *character == 'K' || *character == 'M' || *character == 'B' || *character == 'k' || *character == 'm' || *character == 'b')
|
||||
.collect::<String>();
|
||||
if digits.is_empty() {
|
||||
return None;
|
||||
}
|
||||
parse_abbreviated_number(&digits).map(|views| views as u32)
|
||||
}
|
||||
|
||||
fn parse_rating(value: &str) -> Option<f32> {
|
||||
value
|
||||
.trim()
|
||||
.trim_end_matches('%')
|
||||
.parse::<f32>()
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn parse_video_item_from_anchor(
|
||||
&self,
|
||||
anchor: scraper::ElementRef<'_>,
|
||||
selectors: &FreeusepornSelectors,
|
||||
) -> Option<VideoItem> {
|
||||
let href = anchor.value().attr("href")?;
|
||||
if !href.contains("/video/") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let absolute_url = self.absolute_url(href);
|
||||
let id = absolute_url.split('/').nth(4)?.to_string();
|
||||
if id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let title_raw = anchor
|
||||
.select(&selectors.title)
|
||||
.next()
|
||||
.map(|element| Self::normalized_text(&element.text().collect::<Vec<_>>().join(" ")))
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(|| anchor.value().attr("title").map(Self::normalized_text))
|
||||
.or_else(|| {
|
||||
anchor
|
||||
.select(&selectors.image)
|
||||
.next()
|
||||
.and_then(|element| element.value().attr("alt"))
|
||||
.map(Self::normalized_text)
|
||||
})?;
|
||||
let title = Self::decode_text(&title_raw);
|
||||
|
||||
let thumb = anchor
|
||||
.select(&selectors.image)
|
||||
.next()
|
||||
.and_then(|element| element.value().attr("src"))
|
||||
.map(|src| self.absolute_url(src))
|
||||
.unwrap_or_default();
|
||||
|
||||
let duration = anchor
|
||||
.select(&selectors.duration)
|
||||
.next()
|
||||
.map(|element| Self::normalized_text(&element.text().collect::<Vec<_>>().join(" ")))
|
||||
.and_then(|value| parse_time_to_seconds(&value))
|
||||
.unwrap_or(0) as u32;
|
||||
|
||||
let mut stats = anchor
|
||||
.select(&selectors.video_stat)
|
||||
.map(|element| Self::normalized_text(&element.text().collect::<Vec<_>>().join(" ")))
|
||||
.collect::<Vec<_>>();
|
||||
stats.retain(|value| !value.is_empty());
|
||||
let views = stats.first().and_then(|value| Self::parse_views(value));
|
||||
let rating = stats.get(1).and_then(|value| Self::parse_rating(value));
|
||||
|
||||
let mut item = VideoItem::new(
|
||||
id.clone(),
|
||||
title,
|
||||
absolute_url,
|
||||
"freeuseporn".to_string(),
|
||||
thumb,
|
||||
duration,
|
||||
)
|
||||
.views(views.unwrap_or(0));
|
||||
|
||||
if views.is_none() {
|
||||
item.views = None;
|
||||
}
|
||||
item.rating = rating;
|
||||
item.formats = Some(self.build_formats(&id));
|
||||
|
||||
Some(item)
|
||||
}
|
||||
|
||||
fn get_video_items_from_html(&self, html: &str) -> Vec<VideoItem> {
|
||||
if html.trim().is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let document = Html::parse_document(html);
|
||||
let selectors = FreeusepornSelectors::new();
|
||||
let primary_anchors = document
|
||||
.select(&selectors.list_anchor)
|
||||
.collect::<Vec<_>>();
|
||||
let anchors = if primary_anchors.is_empty() {
|
||||
document
|
||||
.select(&selectors.fallback_anchor)
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
primary_anchors
|
||||
};
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
let mut items = Vec::new();
|
||||
|
||||
for anchor in anchors {
|
||||
let Some(item) = self.parse_video_item_from_anchor(anchor, &selectors) else {
|
||||
continue;
|
||||
};
|
||||
if seen.insert(item.id.clone()) {
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
async fn fetch_listing(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
url: String,
|
||||
options: ServerOptions,
|
||||
error_context: &str,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let old_items = match cache.get(&url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
||||
return Ok(items.clone());
|
||||
}
|
||||
items.clone()
|
||||
}
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
let mut requester = requester_or_default(&options, module_path!(), "missing_requester");
|
||||
let text = match requester.get(&url, None).await {
|
||||
Ok(text) => text,
|
||||
Err(error) => {
|
||||
report_provider_error(
|
||||
"freeuseporn",
|
||||
error_context,
|
||||
&format!("url={url}; error={error}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
|
||||
let items = self.get_video_items_from_html(&text);
|
||||
if items.is_empty() {
|
||||
return Ok(old_items);
|
||||
}
|
||||
|
||||
cache.remove(&url);
|
||||
cache.insert(url, items.clone());
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
sort: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let url = self.build_list_url(sort, page, options.category.as_deref());
|
||||
self.fetch_listing(cache, url, options, "get.request").await
|
||||
}
|
||||
|
||||
async fn query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
query: &str,
|
||||
sort: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let search_base = match self.resolve_search_url(query, &options).await {
|
||||
Ok(url) => url,
|
||||
Err(error) => {
|
||||
report_provider_error(
|
||||
"freeuseporn",
|
||||
"query.search_submit",
|
||||
&error.to_string(),
|
||||
)
|
||||
.await;
|
||||
return Ok(vec![]);
|
||||
}
|
||||
};
|
||||
let url = self.append_sort_and_page(&search_base, sort, page);
|
||||
self.fetch_listing(cache, url, options, "query.request").await
|
||||
}
|
||||
}
|
||||
|
||||
struct FreeusepornSelectors {
|
||||
list_anchor: Selector,
|
||||
fallback_anchor: Selector,
|
||||
title: Selector,
|
||||
image: Selector,
|
||||
duration: Selector,
|
||||
video_stat: Selector,
|
||||
}
|
||||
|
||||
impl FreeusepornSelectors {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
list_anchor: Selector::parse("#videos-list a[href]").expect("valid freeuseporn list selector"),
|
||||
fallback_anchor: Selector::parse("a[href]").expect("valid freeuseporn fallback selector"),
|
||||
title: Selector::parse(".v-name").expect("valid freeuseporn title selector"),
|
||||
image: Selector::parse("img").expect("valid freeuseporn image selector"),
|
||||
duration: Selector::parse(".duration").expect("valid freeuseporn duration selector"),
|
||||
video_stat: Selector::parse(".video-stats li").expect("valid freeuseporn stats selector"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for FreeusepornProvider {
|
||||
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 videos = match query {
|
||||
Some(query) => self.query(cache, page, &query, &sort, options).await,
|
||||
None => self.get(cache, page, &sort, options).await,
|
||||
};
|
||||
|
||||
match videos {
|
||||
Ok(items) => items,
|
||||
Err(error) => {
|
||||
eprintln!("freeuseporn provider error: {error}");
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
||||
Some(self.build_channel(clientversion))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn provider() -> FreeusepornProvider {
|
||||
FreeusepornProvider::new()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_listing_urls_for_sort_category_and_search() {
|
||||
let provider = provider();
|
||||
|
||||
assert_eq!(
|
||||
provider.build_list_url("recent", 1, None),
|
||||
"https://www.freeuseporn.com/videos?o=mr"
|
||||
);
|
||||
assert_eq!(
|
||||
provider.build_list_url("viewed", 2, Some("mind-control")),
|
||||
"https://www.freeuseporn.com/videos/mind-control?o=mv&page=2"
|
||||
);
|
||||
assert_eq!(
|
||||
provider.append_sort_and_page(
|
||||
"https://www.freeuseporn.com/search/videos/Nicole-Kitt",
|
||||
"favorites",
|
||||
3
|
||||
),
|
||||
"https://www.freeuseporn.com/search/videos/Nicole-Kitt?o=tf&page=3"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_search_request_body_with_form_encoding() {
|
||||
assert_eq!(
|
||||
FreeusepornProvider::build_search_request_body("Nicole Kitt & Cory Chase"),
|
||||
"search_query=Nicole+Kitt+%26+Cory+Chase"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_listing_items_and_builds_formats() {
|
||||
let provider = provider();
|
||||
let html = r#"
|
||||
<ul class="grid" id="videos-list">
|
||||
<li>
|
||||
<div class="item">
|
||||
<div class="thumbnail">
|
||||
<div class="embed">
|
||||
<iframe src="https://ads.example"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/video/9579/nicole-kitt-shady-slut-keeps-confessing" class="thumb-wrap-link">
|
||||
<div class="item">
|
||||
<div class="thumbnail overlay" id="playvthumb_9579">
|
||||
<div class="sub-data">
|
||||
<span class="duration">59:09</span>
|
||||
</div>
|
||||
<img src="https://www.freeuseporn.com/media/videos/tmb/9579/1.jpg" alt="Nicole Kitt & The Truth"/>
|
||||
</div>
|
||||
<div class="info">
|
||||
<span class="v-name">Nicole Kitt & The Truth</span>
|
||||
<ul class="video-stats">
|
||||
<li><i class="far fa-eye"></i>52180</li>
|
||||
<li><i class="far fa-heart"></i>100%</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.freeuseporn.com/video/9578/lollipop-time-stop-2">
|
||||
<div class="item">
|
||||
<div class="thumbnail overlay">
|
||||
<div class="sub-data">
|
||||
<span class="duration">16:27</span>
|
||||
</div>
|
||||
<img src="https://www.freeuseporn.com/media/videos/tmb/9578/1.jpg" alt="Lollipop time stop 2"/>
|
||||
</div>
|
||||
<div class="info">
|
||||
<span class="v-name">Lollipop time stop 2</span>
|
||||
<ul class="video-stats">
|
||||
<li><i class="far fa-eye"></i>35058</li>
|
||||
<li><i class="far fa-heart"></i>88%</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
"#;
|
||||
|
||||
let items = provider.get_video_items_from_html(html);
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0].id, "9579");
|
||||
assert_eq!(items[0].title, "Nicole Kitt & The Truth");
|
||||
assert_eq!(
|
||||
items[0].url,
|
||||
"https://www.freeuseporn.com/video/9579/nicole-kitt-shady-slut-keeps-confessing"
|
||||
);
|
||||
assert_eq!(
|
||||
items[0].thumb,
|
||||
"https://www.freeuseporn.com/media/videos/tmb/9579/1.jpg"
|
||||
);
|
||||
assert_eq!(items[0].duration, 3549);
|
||||
assert_eq!(items[0].views, Some(52180));
|
||||
assert_eq!(items[0].rating, Some(100.0));
|
||||
assert_eq!(items[0].formats.as_ref().map(|formats| formats.len()), Some(2));
|
||||
assert_eq!(
|
||||
items[0]
|
||||
.formats
|
||||
.as_ref()
|
||||
.and_then(|formats| formats.first())
|
||||
.map(|format| format.url.as_str()),
|
||||
Some("https://www.freeuseporn.com/media/videos/h264/9579_720p.mp4")
|
||||
);
|
||||
assert_eq!(items[1].id, "9578");
|
||||
assert_eq!(items[1].rating, Some(88.0));
|
||||
}
|
||||
}
|
||||
509
src/providers/hanime.rs
Normal file
@@ -0,0 +1,509 @@
|
||||
use async_trait::async_trait;
|
||||
use error_chain::error_chain;
|
||||
use futures::future::join_all;
|
||||
use serde_json::json;
|
||||
use std::vec;
|
||||
|
||||
use crate::DbPool;
|
||||
use crate::api::ClientVersion;
|
||||
use crate::db;
|
||||
use crate::providers::{Provider, report_provider_error, report_provider_error_background};
|
||||
use crate::status::*;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::videos::{self, ServerOptions, VideoItem};
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "hentai-animation",
|
||||
tags: &["hentai", "anime", "premium"],
|
||||
};
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
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 search_text(mut self, search_text: String) -> Self {
|
||||
self.search_text = search_text;
|
||||
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)]
|
||||
pub struct HanimeProvider;
|
||||
|
||||
impl HanimeProvider {
|
||||
pub fn new() -> Self {
|
||||
HanimeProvider
|
||||
}
|
||||
|
||||
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
|
||||
Channel {
|
||||
id: "hanime".to_string(),
|
||||
name: "Hanime".to_string(),
|
||||
description: "Free Hentai from Hanime".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=hanime.tv".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: "created_at_unix.desc".to_string(),
|
||||
title: "Recent Upload".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "created_at_unix.asc".to_string(),
|
||||
title: "Old Upload".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "views.desc".to_string(),
|
||||
title: "Most Views".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "views.asc".to_string(),
|
||||
title: "Least Views".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "likes.desc".to_string(),
|
||||
title: "Most Likes".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "likes.asc".to_string(),
|
||||
title: "Least Likes".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "released_at_unix.desc".to_string(),
|
||||
title: "New".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "released_at_unix.asc".to_string(),
|
||||
title: "Old".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "title_sortable.asc".to_string(),
|
||||
title: "A - Z".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "title_sortable.desc".to_string(),
|
||||
title: "Z - A".to_string(),
|
||||
},
|
||||
],
|
||||
multiSelect: false,
|
||||
}],
|
||||
nsfw: true,
|
||||
cacheDuration: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_video_item(
|
||||
&self,
|
||||
hit: HanimeSearchResult,
|
||||
pool: DbPool,
|
||||
options: ServerOptions,
|
||||
) -> Result<VideoItem> {
|
||||
let mut conn = match pool.get() {
|
||||
Ok(conn) => conn,
|
||||
Err(e) => {
|
||||
report_provider_error("hanime", "get_video_item.pool_get", &e.to_string()).await;
|
||||
return Err(Error::from("Failed to get DB connection"));
|
||||
}
|
||||
};
|
||||
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 = crate::providers::build_proxy_url(
|
||||
&options,
|
||||
"hanime-cdn",
|
||||
&crate::providers::strip_url_scheme(&hit.cover_url),
|
||||
);
|
||||
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)) => {
|
||||
if video_url != "https://streamable.cloud/hls/stream.m3u8" {
|
||||
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(),
|
||||
)]));
|
||||
} else {
|
||||
match pool.get() {
|
||||
Ok(mut conn) => {
|
||||
let _ = db::delete_video(
|
||||
&mut conn,
|
||||
format!(
|
||||
"https://h.freeanimehentai.net/api/v8/video?id={}&",
|
||||
hit.slug.clone()
|
||||
),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
report_provider_error_background(
|
||||
"hanime",
|
||||
"get_video_item.delete_video.pool_get",
|
||||
&e.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://cached.freeanimehentai.net/api/v8/guest/videos/{}/manifest",
|
||||
id
|
||||
);
|
||||
|
||||
let mut requester =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
let payload = json!({
|
||||
"width": 571, "height": 703, "ab": "kh" }
|
||||
);
|
||||
let _ = requester
|
||||
.post_json(
|
||||
&format!(
|
||||
"https://cached.freeanimehentai.net/api/v8/hentai_videos/{}/play",
|
||||
hit.slug
|
||||
),
|
||||
&payload,
|
||||
vec![
|
||||
("Origin".to_string(), "https://hanime.tv".to_string()),
|
||||
("Referer".to_string(), "https://hanime.tv/".to_string()),
|
||||
],
|
||||
)
|
||||
.await; // Initial request to set cookies
|
||||
ntex::time::sleep(ntex::time::Seconds(1)).await;
|
||||
let text = requester
|
||||
.get_raw_with_headers(
|
||||
&url,
|
||||
vec![
|
||||
("Origin".to_string(), "https://hanime.tv".to_string()),
|
||||
("Referer".to_string(), "https://hanime.tv/".to_string()),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
report_provider_error_background(
|
||||
"hanime",
|
||||
"get_video_item.get_raw_with_headers",
|
||||
&e.to_string(),
|
||||
);
|
||||
Error::from(format!("Failed to fetch manifest response: {e}"))
|
||||
})?
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
report_provider_error_background(
|
||||
"hanime",
|
||||
"get_video_item.response_text",
|
||||
&e.to_string(),
|
||||
);
|
||||
Error::from(format!("Failed to decode manifest response body: {e}"))
|
||||
})?;
|
||||
if text.contains("Unautho") {
|
||||
println!("Fetched video details for {}: {}", title, text);
|
||||
return Err(Error::from("Unauthorized"));
|
||||
}
|
||||
let urls = text
|
||||
.split("streams")
|
||||
.nth(1)
|
||||
.ok_or_else(|| Error::from("Missing streams section in manifest"))?;
|
||||
let mut url_vec = vec![];
|
||||
|
||||
for el in urls.split("\"url\":\"").collect::<Vec<&str>>() {
|
||||
let url = el
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
if !url.is_empty() && url.contains("m3u8") {
|
||||
url_vec.push(url.to_string());
|
||||
}
|
||||
}
|
||||
let first_url = url_vec
|
||||
.first()
|
||||
.cloned()
|
||||
.ok_or_else(|| Error::from("No stream URL found in manifest"))?;
|
||||
match pool.get() {
|
||||
Ok(mut conn) => {
|
||||
let _ = db::insert_video(
|
||||
&mut conn,
|
||||
&format!(
|
||||
"https://h.freeanimehentai.net/api/v8/video?id={}&",
|
||||
hit.slug.clone()
|
||||
),
|
||||
&first_url,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
report_provider_error_background(
|
||||
"hanime",
|
||||
"get_video_item.insert_video.pool_get",
|
||||
&e.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(
|
||||
VideoItem::new(id, title, first_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)
|
||||
.formats(vec![videos::VideoFormat::new(
|
||||
first_url,
|
||||
"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>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
false => "created_at_unix".to_string(),
|
||||
};
|
||||
let ordering = match sort.contains(".") {
|
||||
true => sort
|
||||
.split(".")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
false => "desc".to_string(),
|
||||
};
|
||||
let old_items = match cache.get(&index) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 1 {
|
||||
//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 =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
let response = match requester
|
||||
.post_json("https://search.htv-services.com/search", &search, vec![])
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(e) => {
|
||||
report_provider_error(
|
||||
"hanime",
|
||||
"get.search_request",
|
||||
&format!("query={query}; page={page}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
|
||||
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![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
||||
Some(self.build_channel(clientversion))
|
||||
}
|
||||
}
|
||||
1261
src/providers/heavyfetish.rs
Normal file
566
src/providers/hentaihaven.rs
Normal file
@@ -0,0 +1,566 @@
|
||||
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::videos::{ServerOptions, VideoFormat, VideoItem};
|
||||
use crate::{DbPool, db};
|
||||
|
||||
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::vec;
|
||||
use titlecase::Titlecase;
|
||||
use wreq::Version;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "hentai-animation",
|
||||
tags: &["hentai", "anime", "curated"],
|
||||
};
|
||||
|
||||
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 HentaihavenProvider {
|
||||
url: String,
|
||||
categories: Arc<RwLock<Vec<FilterOption>>>,
|
||||
}
|
||||
|
||||
impl HentaihavenProvider {
|
||||
pub fn new() -> Self {
|
||||
let provider = Self {
|
||||
url: "https://hentaihaven.xxx".to_string(),
|
||||
categories: Arc::new(RwLock::new(vec![])),
|
||||
};
|
||||
provider
|
||||
}
|
||||
|
||||
fn build_channel(&self, clientversion: ClientVersion) -> Channel {
|
||||
let _ = clientversion;
|
||||
Channel {
|
||||
id: "hentaihaven".to_string(),
|
||||
name: "Hentai Haven".to_string(),
|
||||
description: "Watch Free Hentai Videos HD!".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=hentaihaven.xxx".to_string(),
|
||||
status: "active".to_string(),
|
||||
categories: self
|
||||
.categories
|
||||
.read()
|
||||
.map(|categories| categories.iter().map(|c| c.title.clone()).collect())
|
||||
.unwrap_or_else(|e| {
|
||||
crate::providers::report_provider_error_background(
|
||||
"hentaihaven",
|
||||
"build_channel.categories_read",
|
||||
&e.to_string(),
|
||||
);
|
||||
vec![]
|
||||
}),
|
||||
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,
|
||||
pool: DbPool,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let _ = sort;
|
||||
let video_url = format!("{}/hentai/page/{}/", self.url, page);
|
||||
let old_items = match cache.get(&video_url) {
|
||||
Some((time, items)) => {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 60 * 60 * 24 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
items.clone()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let mut requester =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
let text = match requester.get(&video_url, Some(Version::HTTP_2)).await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
crate::providers::report_provider_error(
|
||||
"hentaihaven",
|
||||
"get.request",
|
||||
&format!("url={video_url}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
let video_items: Vec<VideoItem> = self
|
||||
.get_video_items_from_html(text.clone(), &mut requester, pool.clone())
|
||||
.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,
|
||||
pool: DbPool,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let video_url = format!("{}/?s={}", self.url, 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 * 60 * 24 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
let _ = cache.check().await;
|
||||
return Ok(items.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let mut requester =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
let text = match requester.get(&video_url, Some(Version::HTTP_2)).await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
crate::providers::report_provider_error(
|
||||
"hentaihaven",
|
||||
"query.request",
|
||||
&format!("url={video_url}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
if page > 1 {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let video_items: Vec<VideoItem> = self
|
||||
.get_video_items_from_html_search(text.clone(), &mut requester, pool)
|
||||
.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,
|
||||
pool: DbPool,
|
||||
) -> Vec<VideoItem> {
|
||||
if html.is_empty() || html.contains("404 Not Found") {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let block = match html
|
||||
.split("previouspostslink")
|
||||
.next()
|
||||
.and_then(|s| {
|
||||
s.split("vraven_manga_list").nth(1).or_else(|| {
|
||||
s.find(r#"<div class="page-content-listing item-big_thumbnail">"#)
|
||||
.map(|idx| &s[idx..])
|
||||
})
|
||||
})
|
||||
{
|
||||
Some(b) => b,
|
||||
None => {
|
||||
eprint!("Hentai Haven 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("Hentai Haven Provider"),
|
||||
Some(&format!("Failed to get block from html:\n```{html}\n```")),
|
||||
file!(),
|
||||
line!(),
|
||||
module_path!(),
|
||||
)
|
||||
.await;
|
||||
return vec![];
|
||||
}
|
||||
};
|
||||
|
||||
let futures = block
|
||||
.split("id=\"manga-item-")
|
||||
.skip(1)
|
||||
.map(|el| self.get_video_item(el.to_string(), pool.clone(), requester.clone()));
|
||||
join_all(futures)
|
||||
.await
|
||||
.into_iter()
|
||||
.inspect(|r| {
|
||||
if let Err(e) = r {
|
||||
eprint!("Hentai Haven 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("Hentai Haven 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_items_from_html_search(
|
||||
&self,
|
||||
html: String,
|
||||
requester: &mut Requester,
|
||||
pool: DbPool,
|
||||
) -> Vec<VideoItem> {
|
||||
if html.is_empty() || html.contains("404 Not Found") {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let block = match html
|
||||
.split("<footer")
|
||||
.next()
|
||||
.and_then(|s| s.split("content-area").nth(1))
|
||||
{
|
||||
Some(b) => b,
|
||||
None => {
|
||||
eprint!("Hentai Haven 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("Hentai Haven Provider"),
|
||||
Some(&format!("Failed to get block from html:\n```{html}\n```")),
|
||||
file!(),
|
||||
line!(),
|
||||
module_path!(),
|
||||
)
|
||||
.await;
|
||||
return vec![];
|
||||
}
|
||||
};
|
||||
|
||||
let futures = block
|
||||
.split("c-tabs-item__content col-6 col-md-12")
|
||||
.skip(1)
|
||||
.map(|el| self.get_video_item(el.to_string(), pool.clone(), requester.clone()));
|
||||
join_all(futures)
|
||||
.await
|
||||
.into_iter()
|
||||
.inspect(|r| {
|
||||
if let Err(e) = r {
|
||||
eprint!("Hentai Haven 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("Hentai Haven 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,
|
||||
pool: DbPool,
|
||||
mut requester: Requester,
|
||||
) -> Result<VideoItem> {
|
||||
let video_url = seg
|
||||
.split("a href=\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
.ok_or_else(|| ErrorKind::Parse("video url\n\n{seg}".into()))?
|
||||
.to_string();
|
||||
let mut conn = match pool.get() {
|
||||
Ok(conn) => conn,
|
||||
Err(e) => {
|
||||
let msg = format!("DB pool error: {}", e);
|
||||
send_discord_error_report(
|
||||
msg.clone(),
|
||||
None,
|
||||
Some("Hentai Haven Provider"),
|
||||
Some("get_video_item.pool_get"),
|
||||
file!(),
|
||||
line!(),
|
||||
module_path!(),
|
||||
)
|
||||
.await;
|
||||
return Err(msg.into());
|
||||
}
|
||||
};
|
||||
let db_result = db::get_video(&mut conn, video_url.clone());
|
||||
drop(conn);
|
||||
match db_result {
|
||||
Ok(Some(video)) => {
|
||||
let video_item = VideoItem::from(video);
|
||||
match video_item {
|
||||
Ok(item) => return Ok(item),
|
||||
Err(e) => {
|
||||
eprint!("Failed to convert video from DB result: {}\n", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
// continue to fetch and parse the video
|
||||
}
|
||||
Err(e) => {
|
||||
eprint!("Database error: {}\n", e);
|
||||
// continue to fetch and parse the video even if there's a DB error
|
||||
}
|
||||
}
|
||||
let html = requester
|
||||
.get(&video_url, Some(Version::HTTP_2))
|
||||
.await
|
||||
.map_err(|e| Error::from(format!("Failed to fetch video page: {}", e)))?;
|
||||
|
||||
let mut title = html
|
||||
.split("<h1>")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split("</h1>").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(4)
|
||||
.and_then(|s| s.split('.').next())
|
||||
.ok_or_else(|| ErrorKind::Parse("video id\n\n{seg}".into()))?
|
||||
.to_string();
|
||||
let thumb = html
|
||||
.split("og:image\" content=\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let raw_tags: Vec<FilterOption> = html
|
||||
.split("Genre(s)")
|
||||
.nth(1)
|
||||
.unwrap_or_default()
|
||||
.split("Release")
|
||||
.nth(0)
|
||||
.unwrap_or_default()
|
||||
.split("a href=\"")
|
||||
.skip(1)
|
||||
.map(|tag_block| {
|
||||
let id = tag_block
|
||||
.split("\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let title = tag_block
|
||||
.split('>')
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('<').next())
|
||||
.map(|s| {
|
||||
decode(s.as_bytes())
|
||||
.to_string()
|
||||
.unwrap_or(s.to_string())
|
||||
.titlecase()
|
||||
})
|
||||
.unwrap_or("".to_string());
|
||||
FilterOption {
|
||||
id: id.to_ascii_lowercase().replace(" ", "+"),
|
||||
title: title.clone(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<FilterOption>>();
|
||||
for tag in &raw_tags {
|
||||
Self::push_unique(&self.categories, tag.clone());
|
||||
}
|
||||
let tags = raw_tags.into_iter().map(|t| t.title).collect();
|
||||
let views = html
|
||||
.split("Viewed")
|
||||
.last()
|
||||
.and_then(|s| s.split("summary-content\">").nth(1))
|
||||
.and_then(|s| s.split(" Total").nth(0))
|
||||
.map(|s| s.trim().parse::<u32>().unwrap_or(0))
|
||||
.unwrap_or(0);
|
||||
let mut formats = vec![];
|
||||
let episode_block = html
|
||||
.split("manga-chapters-holder")
|
||||
.nth(1)
|
||||
.unwrap_or_default()
|
||||
.split("vraven_read")
|
||||
.nth(0)
|
||||
.unwrap_or_default();
|
||||
for episode in episode_block.split("wp-manga-chapter").skip(1) {
|
||||
let ep_thumbnail = episode
|
||||
.split(" src=\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
.unwrap_or_default();
|
||||
let episode_title = episode
|
||||
.split("<div>")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('<').next())
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
let episode_id = ep_thumbnail.split('/').nth(5).unwrap_or_default();
|
||||
let episode_url = format!(
|
||||
"https://master-lengs.org/api/v3/hh/{}/master.m3u8",
|
||||
episode_id
|
||||
);
|
||||
let format = VideoFormat::new(episode_url, "1080p".to_string(), "m3u8".to_string())
|
||||
.format_id(episode_title.clone())
|
||||
.http_header("Connection".to_string(), "keep-alive".to_string())
|
||||
.http_header(
|
||||
"User-Agent".to_string(),
|
||||
"Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0"
|
||||
.to_string(),
|
||||
)
|
||||
.http_header(
|
||||
"Accept".to_string(),
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8".to_string(),
|
||||
)
|
||||
.http_header("Accept-Language".to_string(), "en-US,en;q=0.5".to_string())
|
||||
.http_header(
|
||||
"Accept-Encoding".to_string(),
|
||||
"gzip, deflate, br".to_string(),
|
||||
)
|
||||
.http_header("Sec-Fetch-Mode".to_string(), "navigate".to_string())
|
||||
.http_header("Origin".to_string(), self.url.clone())
|
||||
.format_note(episode_title.clone());
|
||||
formats.push(format);
|
||||
}
|
||||
if formats.is_empty() {
|
||||
let e = Error::from(format!("No formats found for video URL: {}", video_url));
|
||||
return Err(e);
|
||||
}
|
||||
if formats.len() > 1 {
|
||||
title = format!("{} ({} Episodes)", title, formats.len());
|
||||
}
|
||||
|
||||
let video_item =
|
||||
VideoItem::new(id, title, video_url.clone(), "hentaihaven".into(), thumb, 0)
|
||||
.formats(formats)
|
||||
.tags(tags)
|
||||
.views(views)
|
||||
.aspect_ratio(0.715);
|
||||
|
||||
match pool.get() {
|
||||
Ok(mut conn) => {
|
||||
let _ = db::insert_video(
|
||||
&mut conn,
|
||||
&video_url,
|
||||
&serde_json::to_string(&video_item).unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
send_discord_error_report(
|
||||
format!("DB pool error: {}", e),
|
||||
None,
|
||||
Some("Hentai Haven Provider"),
|
||||
Some("get_video_item.insert_video.pool_get"),
|
||||
file!(),
|
||||
line!(),
|
||||
module_path!(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(video_item)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for HentaihavenProvider {
|
||||
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, pool).await,
|
||||
None => self.get(cache, page, &sort, options, pool).await,
|
||||
};
|
||||
|
||||
res.unwrap_or_else(|e| {
|
||||
eprintln!("hentai haven error: {e}");
|
||||
vec![]
|
||||
})
|
||||
}
|
||||
|
||||
fn get_channel(&self, v: ClientVersion) -> Option<Channel> {
|
||||
Some(self.build_channel(v))
|
||||
}
|
||||
}
|
||||
429
src/providers/homoxxx.rs
Normal file
@@ -0,0 +1,429 @@
|
||||
use crate::DbPool;
|
||||
use crate::api::ClientVersion;
|
||||
use crate::providers::{Provider, report_provider_error};
|
||||
use crate::status::*;
|
||||
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;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "gay-male",
|
||||
tags: &["gay", "male", "tube"],
|
||||
};
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
|
||||
Channel {
|
||||
id: "homoxxx".to_string(),
|
||||
name: "Homo.xxx".to_string(),
|
||||
description: "Best Gay Porn".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=homo.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: "new".to_string(),
|
||||
title: "New".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "popular".to_string(),
|
||||
title: "Popular".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "trending".to_string(),
|
||||
title: "Trending".to_string(),
|
||||
},
|
||||
],
|
||||
multiSelect: false,
|
||||
}],
|
||||
nsfw: true,
|
||||
cacheDuration: Some(1800),
|
||||
}
|
||||
}
|
||||
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() {
|
||||
let location = match response
|
||||
.headers()
|
||||
.get("Location")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
{
|
||||
Some(location) => location,
|
||||
None => {
|
||||
report_provider_error(
|
||||
"homoxxx",
|
||||
"get.redirect_location",
|
||||
&format!("url={video_url}; missing/invalid Location header"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
println!("Redirection detected, following to: {}", location);
|
||||
response = client
|
||||
.get(location)
|
||||
// .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 = match env::var("FLARE_URL") {
|
||||
Ok(url) => url,
|
||||
Err(e) => {
|
||||
report_provider_error("homoxxx", "get.flare_url", &e.to_string()).await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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() {
|
||||
let location = match response
|
||||
.headers()
|
||||
.get("Location")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
{
|
||||
Some(location) => location,
|
||||
None => {
|
||||
report_provider_error(
|
||||
"homoxxx",
|
||||
"query.redirect_location",
|
||||
&format!("url={video_url}; missing/invalid Location header"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
response = client
|
||||
.get(self.url.clone() + location)
|
||||
// .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 = match env::var("FLARE_URL") {
|
||||
Ok(url) => url,
|
||||
Err(e) => {
|
||||
report_provider_error("homoxxx", "query.flare_url", &e.to_string()).await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
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>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let preview_url = video_segment
|
||||
.split("data-preview-custom=\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let mut title = video_segment
|
||||
.split("\" title=\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
// html decode
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
let id = video_url
|
||||
.split("/")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(4)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let raw_duration = video_segment
|
||||
.split("<p class=\"duration_item\">")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("data-src=\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
||||
Some(self.build_channel(clientversion))
|
||||
}
|
||||
}
|
||||
586
src/providers/hqporner.rs
Normal file
@@ -0,0 +1,586 @@
|
||||
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::stream::{FuturesUnordered, StreamExt};
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::{thread, vec};
|
||||
use titlecase::Titlecase;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "studio-network",
|
||||
tags: &["studio", "hd", "scenes"],
|
||||
};
|
||||
|
||||
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, &options)
|
||||
.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, &options)
|
||||
.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,
|
||||
options: &ServerOptions,
|
||||
) -> 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();
|
||||
|
||||
// Limit concurrent detail-page requests to reduce transient connect errors.
|
||||
let mut in_flight = FuturesUnordered::new();
|
||||
let mut iter = raw_videos.into_iter();
|
||||
let mut items = Vec::new();
|
||||
const MAX_IN_FLIGHT: usize = 6;
|
||||
|
||||
loop {
|
||||
while in_flight.len() < MAX_IN_FLIGHT {
|
||||
let Some(seg) = iter.next() else {
|
||||
break;
|
||||
};
|
||||
in_flight.push(self.get_video_item(seg, requester.clone(), options));
|
||||
}
|
||||
|
||||
let Some(result) = in_flight.next().await else {
|
||||
break;
|
||||
};
|
||||
match result {
|
||||
Ok(item)
|
||||
if item
|
||||
.formats
|
||||
.as_ref()
|
||||
.map(|formats| !formats.is_empty())
|
||||
.unwrap_or(false) =>
|
||||
{
|
||||
items.push(item);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
async fn get_video_item(
|
||||
&self,
|
||||
seg: String,
|
||||
mut requester: Requester,
|
||||
options: &ServerOptions,
|
||||
) -> 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_raw = seg
|
||||
.split("onmouseleave='defaultImage(\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
.ok_or_else(|| ErrorKind::Parse(format!("thumb \n{seg}").into()))?;
|
||||
let thumb_abs = if thumb_raw.starts_with("//") {
|
||||
format!("https:{}", thumb_raw)
|
||||
} else if thumb_raw.starts_with("http://") || thumb_raw.starts_with("https://") {
|
||||
thumb_raw.to_string()
|
||||
} else {
|
||||
format!("https://{}", thumb_raw.trim_start_matches('/'))
|
||||
};
|
||||
let thumb = match thumb_abs.strip_prefix("https://") {
|
||||
Some(path) => crate::providers::build_proxy_url(options, "hqporner-thumb", path),
|
||||
None => thumb_abs,
|
||||
};
|
||||
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 headers = vec![("Referer".to_string(), "https://hqporner.com/".into())];
|
||||
let mut text = match self
|
||||
.fetch_text_with_retries(requester, url, &headers, 3)
|
||||
.await
|
||||
{
|
||||
Ok(text) => text,
|
||||
Err(primary_err) => {
|
||||
if url.contains("://hqporner.com/") {
|
||||
let fallback_url = url.replace("://hqporner.com/", "://www.hqporner.com/");
|
||||
self.fetch_text_with_retries(requester, &fallback_url, &headers, 3)
|
||||
.await
|
||||
.map_err(|fallback_err| {
|
||||
Error::from(format!(
|
||||
"Request failed: primary={primary_err}; fallback={fallback_err}"
|
||||
))
|
||||
})?
|
||||
} else {
|
||||
return Err(Error::from(format!("Request failed: {}", primary_err)));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if text.is_empty() && url.contains("://hqporner.com/") {
|
||||
let fallback_url = url.replace("://hqporner.com/", "://www.hqporner.com/");
|
||||
text = self
|
||||
.fetch_text_with_retries(requester, &fallback_url, &headers, 3)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
}
|
||||
|
||||
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 response_text = match self
|
||||
.fetch_text_with_retries(requester, &player_url, &headers, 2)
|
||||
.await
|
||||
{
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
let err = format!("altplayer request failed: {e}");
|
||||
send_discord_error_report(
|
||||
err.clone(),
|
||||
None,
|
||||
Some("Hqporner Provider"),
|
||||
Some(&player_url),
|
||||
file!(),
|
||||
line!(),
|
||||
module_path!(),
|
||||
)
|
||||
.await;
|
||||
return Ok((tags, formats));
|
||||
}
|
||||
};
|
||||
let text2 = response_text;
|
||||
|
||||
// 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 fn fetch_text_with_retries(
|
||||
&self,
|
||||
requester: &mut Requester,
|
||||
url: &str,
|
||||
headers: &[(String, String)],
|
||||
max_attempts: u8,
|
||||
) -> std::result::Result<String, String> {
|
||||
let mut last_err = String::new();
|
||||
|
||||
for attempt in 1..=max_attempts {
|
||||
match requester.get_raw_with_headers(url, headers.to_vec()).await {
|
||||
Ok(resp) => match resp.text().await {
|
||||
Ok(text) => return Ok(text),
|
||||
Err(e) => {
|
||||
last_err =
|
||||
format!("text read failed (attempt {attempt}/{max_attempts}): {e}");
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
last_err = format!("request failed (attempt {attempt}/{max_attempts}): {e}");
|
||||
}
|
||||
}
|
||||
|
||||
if attempt < max_attempts {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(250 * attempt as u64)).await;
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_err)
|
||||
}
|
||||
}
|
||||
|
||||
#[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))
|
||||
}
|
||||
}
|
||||
1368
src/providers/hsex.rs
Normal file
469
src/providers/hypnotube.rs
Normal file
@@ -0,0 +1,469 @@
|
||||
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;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "fetish-kink",
|
||||
tags: &["hypnosis", "fetish", "sissy"],
|
||||
};
|
||||
|
||||
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()
|
||||
.map(|categories| categories.iter().map(|c| c.title.clone()).collect())
|
||||
.unwrap_or_else(|e| {
|
||||
eprint!("Hypnotube categories lock error: {e}");
|
||||
crate::providers::report_provider_error_background(
|
||||
"hypnotube",
|
||||
"build_channel.categories_read",
|
||||
&e.to_string(),
|
||||
);
|
||||
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: "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 =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
let text = match requester.get(&video_url, Some(Version::HTTP_11)).await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
crate::providers::report_provider_error(
|
||||
"hypnotube",
|
||||
"get.request",
|
||||
&format!("url={video_url}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return old_items;
|
||||
}
|
||||
};
|
||||
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 =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
let post_response = 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
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(e) => {
|
||||
crate::providers::report_provider_error(
|
||||
"hypnotube",
|
||||
"query.search_post",
|
||||
&format!("url={video_url}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return old_items;
|
||||
}
|
||||
};
|
||||
let text = match post_response.text().await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
eprint!("Hypnotube search POST request failed: {}", e);
|
||||
crate::providers::report_provider_error_background(
|
||||
"hypnotube",
|
||||
"query.search_post.text",
|
||||
&e.to_string(),
|
||||
);
|
||||
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))
|
||||
}
|
||||
}
|
||||
516
src/providers/javtiful.rs
Normal file
@@ -0,0 +1,516 @@
|
||||
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::vec;
|
||||
use titlecase::Titlecase;
|
||||
use wreq::Version;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "jav",
|
||||
tags: &["jav", "asian", "streaming"],
|
||||
};
|
||||
|
||||
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()
|
||||
.map(|categories| categories.iter().map(|c| c.title.clone()).collect())
|
||||
.unwrap_or_else(|e| {
|
||||
crate::providers::report_provider_error_background(
|
||||
"javtiful",
|
||||
"build_channel.categories_read",
|
||||
&e.to_string(),
|
||||
);
|
||||
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: "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 =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
let text = match requester.get(&video_url, Some(Version::HTTP_2)).await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
crate::providers::report_provider_error(
|
||||
"javtiful",
|
||||
"get.request",
|
||||
&format!("url={video_url}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
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, &options)
|
||||
.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 =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
let text = match requester.get(&video_url, Some(Version::HTTP_2)).await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
crate::providers::report_provider_error(
|
||||
"javtiful",
|
||||
"query.request",
|
||||
&format!("url={video_url}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
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, &options)
|
||||
.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,
|
||||
options: &ServerOptions,
|
||||
) -> 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(), options));
|
||||
|
||||
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,
|
||||
options: &ServerOptions,
|
||||
) -> 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, mut formats, views) = self
|
||||
.extract_media(&video_url, &mut requester, options)
|
||||
.await?;
|
||||
|
||||
if preview.len() == 0 {
|
||||
preview = format!("https://trailers.jav.si/preview/{id}.mp4");
|
||||
}
|
||||
if formats.is_empty() && !preview.is_empty() {
|
||||
let mut format = VideoFormat::new(preview.clone(), "preview".to_string(), "video/mp4".to_string());
|
||||
format.add_http_header("Referer".to_string(), video_url.clone());
|
||||
formats.push(format);
|
||||
}
|
||||
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,
|
||||
options: &ServerOptions,
|
||||
) -> 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 mut formats = Vec::new();
|
||||
let video_id = url
|
||||
.split("/video/")
|
||||
.nth(1)
|
||||
.and_then(|value| value.split('/').next())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
let token = text
|
||||
.split("data-csrf-token=\"")
|
||||
.nth(1)
|
||||
.and_then(|value| value.split('"').next())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
|
||||
if !video_id.is_empty() && !token.is_empty() {
|
||||
let form = wreq::multipart::Form::new()
|
||||
.text("video_id", video_id.to_string())
|
||||
.text("pid_c", "".to_string())
|
||||
.text("token", token.to_string());
|
||||
|
||||
if let Ok(response) = requester
|
||||
.post_multipart(
|
||||
"https://javtiful.com/ajax/get_cdn",
|
||||
form,
|
||||
vec![("Referer".to_string(), url.to_string())],
|
||||
Some(Version::HTTP_11),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let payload = response.text().await.unwrap_or_default();
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&payload) {
|
||||
if let Some(cdn_url) = json.get("playlists").and_then(|value| value.as_str()) {
|
||||
if !cdn_url.trim().is_empty() {
|
||||
let mut format = VideoFormat::new(
|
||||
cdn_url.to_string(),
|
||||
quality.clone(),
|
||||
"m3u8".into(),
|
||||
);
|
||||
format.add_http_header("Referer".to_string(), url.to_string());
|
||||
formats.push(format);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = options;
|
||||
|
||||
Ok((tags, formats, 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))
|
||||
}
|
||||
}
|
||||
485
src/providers/missav.rs
Normal file
@@ -0,0 +1,485 @@
|
||||
use crate::DbPool;
|
||||
use crate::api::ClientVersion;
|
||||
use crate::db;
|
||||
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::videos::VideoFormat;
|
||||
use crate::videos::ServerOptions;
|
||||
use crate::videos::VideoItem;
|
||||
use async_trait::async_trait;
|
||||
use diesel::r2d2;
|
||||
use error_chain::error_chain;
|
||||
use futures::future::join_all;
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
use std::vec;
|
||||
use wreq::Version;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "jav",
|
||||
tags: &["jav", "asian", "uncensored"],
|
||||
};
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
|
||||
Channel {
|
||||
id: "missav".to_string(),
|
||||
name: "MissAV".to_string(),
|
||||
description: "Watch HD JAV Online".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![
|
||||
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: "released_at".to_string(),
|
||||
title: "Release Date".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "published_at".to_string(),
|
||||
title: "Recent Update".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "today_views".to_string(),
|
||||
title: "Today Views".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "weekly_views".to_string(),
|
||||
title: "Weekly Views".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "monthly_views".to_string(),
|
||||
title: "Monthly Views".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "views".to_string(),
|
||||
title: "Total Views".to_string(),
|
||||
},
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
ChannelOption {
|
||||
id: "filter".to_string(),
|
||||
title: "Filter".to_string(),
|
||||
description: "Filter the Videos".to_string(),
|
||||
systemImage: "line.horizontal.3.decrease.circle".to_string(),
|
||||
colorName: "green".to_string(),
|
||||
options: vec![
|
||||
FilterOption {
|
||||
id: "new".to_string(),
|
||||
title: "Recent update".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "release".to_string(),
|
||||
title: "New Releases".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "uncensored-leak".to_string(),
|
||||
title: "Uncensored".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "english-subtitle".to_string(),
|
||||
title: "English subtitle".to_string(),
|
||||
},
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
ChannelOption {
|
||||
id: "language".to_string(),
|
||||
title: "Language".to_string(),
|
||||
description: "What Language to fetch".to_string(),
|
||||
systemImage: "flag.fill".to_string(),
|
||||
colorName: "gray".to_string(),
|
||||
options: vec![
|
||||
FilterOption {
|
||||
id: "en".to_string(),
|
||||
title: "English".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "cn".to_string(),
|
||||
title: "简体中文".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "ja".to_string(),
|
||||
title: "日本語".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "ko".to_string(),
|
||||
title: "한국의".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "ms".to_string(),
|
||||
title: "Melayu".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "th".to_string(),
|
||||
title: "ไทย".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "de".to_string(),
|
||||
title: "Deutsch".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "fr".to_string(),
|
||||
title: "Français".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "vi".to_string(),
|
||||
title: "Tiếng Việt".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "id".to_string(),
|
||||
title: "Bahasa Indonesia".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "fil".to_string(),
|
||||
title: "Filipino".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "pt".to_string(),
|
||||
title: "Português".to_string(),
|
||||
},
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
nsfw: true,
|
||||
cacheDuration: None,
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
Some(format!(
|
||||
"https://{}.{}/{}-{}-{}-{}-{}/playlist.m3u8",
|
||||
parts.get(1)?,
|
||||
parts.get(2)?,
|
||||
parts.get(3)?,
|
||||
parts.get(4)?,
|
||||
parts.get(5)?,
|
||||
parts.get(6)?,
|
||||
parts.get(7)?
|
||||
))
|
||||
})()
|
||||
.ok_or_else(|| ErrorKind::ParsingError(format!("video_url\n{:?}", vid).to_string()))?;
|
||||
|
||||
let mut format = VideoFormat::new(video_url.clone(), "auto".to_string(), "m3u8".to_string());
|
||||
format.add_http_header("Referer".to_string(), "https://missav.ws/".to_string());
|
||||
let video_item = VideoItem::new(id, title, video_url, "missav".to_string(), thumb, duration)
|
||||
.formats(vec![format])
|
||||
.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![]
|
||||
})
|
||||
}
|
||||
|
||||
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
||||
Some(self.build_channel(clientversion))
|
||||
}
|
||||
}
|
||||
1641
src/providers/mod.rs
771
src/providers/noodlemagazine.rs
Normal file
@@ -0,0 +1,771 @@
|
||||
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, VideoFormat, VideoItem};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use error_chain::error_chain;
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
use regex::Regex;
|
||||
use std::net::IpAddr;
|
||||
use std::vec;
|
||||
use titlecase::Titlecase;
|
||||
use url::Url;
|
||||
use wreq::Version;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "mainstream-tube",
|
||||
tags: &["search", "mixed", "user-upload"],
|
||||
};
|
||||
|
||||
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![
|
||||
ChannelOption {
|
||||
id: "category".into(),
|
||||
title: "Popular Period".into(),
|
||||
description: "Pick which popular feed to browse.".into(),
|
||||
systemImage: "clock".into(),
|
||||
colorName: "blue".into(),
|
||||
options: vec![
|
||||
FilterOption {
|
||||
id: "recent".into(),
|
||||
title: "Recent".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "week".into(),
|
||||
title: "This Week".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "month".into(),
|
||||
title: "This Month".into(),
|
||||
},
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
ChannelOption {
|
||||
id: "sort".into(),
|
||||
title: "Sort By".into(),
|
||||
description: "Sort popular feed results.".into(),
|
||||
systemImage: "arrow.up.arrow.down".into(),
|
||||
colorName: "orange".into(),
|
||||
options: vec![
|
||||
FilterOption {
|
||||
id: "views".into(),
|
||||
title: "Views".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "date".into(),
|
||||
title: "Newest".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "duration".into(),
|
||||
title: "Duration".into(),
|
||||
},
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
ChannelOption {
|
||||
id: "filter".into(),
|
||||
title: "Order".into(),
|
||||
description: "Ascending or descending order.".into(),
|
||||
systemImage: "list.number".into(),
|
||||
colorName: "green".into(),
|
||||
options: vec![
|
||||
FilterOption {
|
||||
id: "desc".into(),
|
||||
title: "Descending".into(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "asc".into(),
|
||||
title: "Ascending".into(),
|
||||
},
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
nsfw: true,
|
||||
cacheDuration: Some(1800),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_popular_period(options: &ServerOptions) -> &'static str {
|
||||
match options.category.as_deref() {
|
||||
Some("week") => "week",
|
||||
Some("month") => "month",
|
||||
// The upstream site does not expose a valid /popular/all route.
|
||||
// Keep "all" as a backward-compatible alias for stale clients.
|
||||
Some("all") => "recent",
|
||||
_ => "recent",
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_sort_by(sort: &str, options: &ServerOptions) -> &'static str {
|
||||
match options.sort.as_deref().unwrap_or(sort) {
|
||||
"date" | "new" | "latest" => "date",
|
||||
"duration" | "length" => "duration",
|
||||
_ => "views",
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_sort_order(options: &ServerOptions) -> &'static str {
|
||||
match options.filter.as_deref() {
|
||||
Some("asc") => "asc",
|
||||
_ => "desc",
|
||||
}
|
||||
}
|
||||
|
||||
fn mirror_url(url: &str) -> String {
|
||||
let stripped = url
|
||||
.strip_prefix("https://")
|
||||
.or_else(|| url.strip_prefix("http://"))
|
||||
.unwrap_or(url);
|
||||
format!("https://r.jina.ai/http://{stripped}")
|
||||
}
|
||||
|
||||
fn looks_like_bot_challenge_or_block(html: &str) -> bool {
|
||||
let lower = html.to_ascii_lowercase();
|
||||
lower.contains("just a moment")
|
||||
|| lower.contains("cf-browser-verification")
|
||||
|| lower.contains("cf-chl")
|
||||
|| lower.contains("access restricted")
|
||||
|| lower.contains("cloudflare")
|
||||
}
|
||||
|
||||
fn parse_markdown_listing_items(
|
||||
&self,
|
||||
markdown: &str,
|
||||
options: &ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let Some(regex) = Regex::new(
|
||||
r#"(?is)\[\!\[Image\s+\d+:\s*(?P<title>.*?)\]\((?P<thumb>https?://[^)\s]+)\)(?P<meta>.*?)\]\((?P<url>https?://noodlemagazine\.com/watch/[^)\s]+)\)"#,
|
||||
)
|
||||
.ok() else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(duration_regex) = Regex::new(r"(?P<duration>\d{1,2}:\d{2}(?::\d{2})?)").ok() else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(views_regex) = Regex::new(r"(?P<views>[0-9]+(?:\.[0-9]+)?[KMB]?)\s+\d{1,2}:\d{2}(?::\d{2})?").ok() else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
regex
|
||||
.captures_iter(markdown)
|
||||
.filter_map(|caps| {
|
||||
let title_raw = caps.name("title")?.as_str().trim();
|
||||
let thumb = caps.name("thumb")?.as_str().trim();
|
||||
let video_url = caps.name("url")?.as_str().trim();
|
||||
let meta = caps.name("meta").map(|m| m.as_str()).unwrap_or("");
|
||||
|
||||
let parsed_url = Url::parse(video_url).ok()?;
|
||||
let id = parsed_url
|
||||
.path_segments()
|
||||
.and_then(|mut segs| segs.next_back())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.to_string())?;
|
||||
|
||||
let duration = duration_regex
|
||||
.captures(meta)
|
||||
.and_then(|m| m.name("duration").map(|v| v.as_str()))
|
||||
.and_then(|v| parse_time_to_seconds(v))
|
||||
.unwrap_or(0) as u32;
|
||||
|
||||
let views = views_regex
|
||||
.captures(meta)
|
||||
.and_then(|m| m.name("views").map(|v| v.as_str()))
|
||||
.and_then(|v| parse_abbreviated_number(v.trim()))
|
||||
.unwrap_or(0);
|
||||
|
||||
let title = decode(title_raw.as_bytes())
|
||||
.to_string()
|
||||
.unwrap_or_else(|_| title_raw.to_string())
|
||||
.titlecase();
|
||||
let proxy_url = self.proxy_url(options, video_url);
|
||||
let proxied_thumb = self.proxied_thumb(options, thumb);
|
||||
|
||||
Some(
|
||||
VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
proxy_url.clone(),
|
||||
"noodlemagazine".into(),
|
||||
proxied_thumb,
|
||||
duration,
|
||||
)
|
||||
.views(views)
|
||||
.formats(vec![
|
||||
VideoFormat::new(proxy_url, "auto".into(), "video/mp4".into())
|
||||
.format_id("auto".into())
|
||||
.format_note("proxied".into())
|
||||
.http_header("Referer".into(), video_url.to_string()),
|
||||
]),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn fetch_listing_items(
|
||||
&self,
|
||||
requester: &mut crate::util::requester::Requester,
|
||||
page_url: &str,
|
||||
options: &ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let html = requester
|
||||
.get(page_url, Some(Version::HTTP_2))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let mut items = self.get_video_items_from_html(html.clone(), options);
|
||||
if !items.is_empty() {
|
||||
return items;
|
||||
}
|
||||
|
||||
if !Self::looks_like_bot_challenge_or_block(&html) {
|
||||
return items;
|
||||
}
|
||||
|
||||
let mirror = requester
|
||||
.get(&Self::mirror_url(page_url), Some(Version::HTTP_11))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
items = self.parse_markdown_listing_items(&mirror, options);
|
||||
items
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
sort: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let period = Self::resolve_popular_period(&options);
|
||||
let sort_by = Self::resolve_sort_by(sort, &options);
|
||||
let sort_order = Self::resolve_sort_order(&options);
|
||||
let video_url = format!(
|
||||
"{}/popular/{period}?sort_by={sort_by}&sort_order={sort_order}&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 items = self
|
||||
.fetch_listing_items(&mut requester, &video_url, &options)
|
||||
.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 items = self
|
||||
.fetch_listing_items(&mut requester, &video_url, &options)
|
||||
.await;
|
||||
|
||||
if items.is_empty() {
|
||||
Ok(old_items)
|
||||
} else {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url, items.clone());
|
||||
Ok(items)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_video_items_from_html(&self, html: String, options: &ServerOptions) -> 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![],
|
||||
};
|
||||
|
||||
list.split("<div class=\"item")
|
||||
.skip(1)
|
||||
.filter_map(|segment| {
|
||||
self.get_video_item(segment.to_string(), options).ok()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn proxy_url(&self, options: &ServerOptions, video_url: &str) -> String {
|
||||
crate::providers::build_proxy_url(
|
||||
options,
|
||||
"noodlemagazine",
|
||||
&crate::providers::strip_url_scheme(video_url),
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_thumb_url(&self, thumb: &str) -> String {
|
||||
let thumb = thumb.trim();
|
||||
if thumb.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
if thumb.starts_with("http://") || thumb.starts_with("https://") {
|
||||
return thumb.to_string();
|
||||
}
|
||||
|
||||
if thumb.starts_with("//") {
|
||||
return format!("https:{thumb}");
|
||||
}
|
||||
|
||||
if thumb.starts_with('/') {
|
||||
return format!("{}{}", self.url, thumb);
|
||||
}
|
||||
|
||||
format!("{}/{}", self.url.trim_end_matches('/'), thumb.trim_start_matches('/'))
|
||||
}
|
||||
|
||||
fn has_allowed_image_extension(path: &str) -> bool {
|
||||
let path = path.to_ascii_lowercase();
|
||||
[".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif"]
|
||||
.iter()
|
||||
.any(|ext| path.ends_with(ext))
|
||||
}
|
||||
|
||||
fn is_known_preview_host(host: &str) -> bool {
|
||||
let host = host.to_ascii_lowercase();
|
||||
host.ends_with("pvvstream.pro")
|
||||
|| host.ends_with("okcdn.ru")
|
||||
|| host.ends_with("vkuserphoto.ru")
|
||||
|| host.ends_with("noodlemagazine.com")
|
||||
}
|
||||
|
||||
fn has_preview_signature(url: &Url) -> bool {
|
||||
let path = url.path().to_ascii_lowercase();
|
||||
let query = url.query().unwrap_or("").to_ascii_lowercase();
|
||||
path.contains("/preview/")
|
||||
|| path.contains("/poster/")
|
||||
|| path.contains("getvideopreview")
|
||||
|| query.contains("type=video_thumb")
|
||||
|| query.contains("keep_aspect_ratio=")
|
||||
}
|
||||
|
||||
fn is_disallowed_thumb_host(host: &str) -> bool {
|
||||
if host.eq_ignore_ascii_case("localhost") {
|
||||
return true;
|
||||
}
|
||||
|
||||
match host.parse::<IpAddr>() {
|
||||
Ok(IpAddr::V4(ip)) => {
|
||||
ip.is_private()
|
||||
|| ip.is_loopback()
|
||||
|| ip.is_link_local()
|
||||
|| ip.is_broadcast()
|
||||
|| ip.is_documentation()
|
||||
|| ip.is_unspecified()
|
||||
}
|
||||
Ok(IpAddr::V6(ip)) => {
|
||||
ip.is_loopback()
|
||||
|| ip.is_unspecified()
|
||||
|| ip.is_multicast()
|
||||
|| ip.is_unique_local()
|
||||
|| ip.is_unicast_link_local()
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_allowed_thumb_url(&self, url: &str) -> bool {
|
||||
let Some(url) = Url::parse(url).ok() else {
|
||||
return false;
|
||||
};
|
||||
if url.scheme() != "https" {
|
||||
return false;
|
||||
}
|
||||
let Some(host) = url.host_str() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if Self::is_disallowed_thumb_host(host) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if Self::has_allowed_image_extension(url.path()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Self::is_known_preview_host(host) && Self::has_preview_signature(&url)
|
||||
}
|
||||
|
||||
fn proxied_thumb(&self, _options: &ServerOptions, thumb: &str) -> String {
|
||||
let normalized = self.normalize_thumb_url(thumb);
|
||||
if normalized.is_empty() || !self.is_allowed_thumb_url(&normalized) {
|
||||
return String::new();
|
||||
}
|
||||
let Some(url) = Url::parse(&normalized).ok() else {
|
||||
return String::new();
|
||||
};
|
||||
if url
|
||||
.host_str()
|
||||
.is_some_and(|host| host.eq_ignore_ascii_case("img.pvvstream.pro"))
|
||||
{
|
||||
return crate::providers::build_proxy_url(
|
||||
_options,
|
||||
"noodlemagazine-thumb",
|
||||
&crate::providers::strip_url_scheme(&normalized),
|
||||
);
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
fn get_video_item(&self, video_segment: String, options: &ServerOptions) -> 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 = Regex::new(
|
||||
r#"(?i)(?:data-src|data-original|data-webp|src|poster)\s*=\s*"(?P<url>[^"]+)""#,
|
||||
)
|
||||
.ok()
|
||||
.and_then(|regex| {
|
||||
regex
|
||||
.captures_iter(&video_segment)
|
||||
.filter_map(|captures| captures.name("url").map(|value| value.as_str().to_string()))
|
||||
.find(|candidate| !candidate.starts_with("data:image/"))
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
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 proxy_url = self.proxy_url(options, &video_url);
|
||||
let proxied_thumb = self.proxied_thumb(options, &thumb);
|
||||
|
||||
Ok(VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
proxy_url.clone(),
|
||||
"noodlemagazine".into(),
|
||||
proxied_thumb,
|
||||
duration,
|
||||
)
|
||||
.views(views)
|
||||
.formats(vec![
|
||||
VideoFormat::new(proxy_url, "auto".into(), "video/mp4".into())
|
||||
.format_id("auto".into())
|
||||
.format_note("proxied".into())
|
||||
.http_header("Referer".into(), video_url),
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
#[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))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::NoodlemagazineProvider;
|
||||
use crate::videos::ServerOptions;
|
||||
|
||||
fn options() -> ServerOptions {
|
||||
ServerOptions {
|
||||
featured: None,
|
||||
category: None,
|
||||
sites: None,
|
||||
filter: None,
|
||||
language: None,
|
||||
public_url_base: Some("https://example.com".to_string()),
|
||||
requester: None,
|
||||
network: None,
|
||||
stars: None,
|
||||
categories: None,
|
||||
duration: None,
|
||||
sort: None,
|
||||
sexuality: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrites_video_pages_to_hottub_proxy() {
|
||||
let provider = NoodlemagazineProvider::new();
|
||||
let options = options();
|
||||
|
||||
assert_eq!(
|
||||
provider.proxy_url(&options, "https://noodlemagazine.com/watch/-123_456"),
|
||||
"https://example.com/proxy/noodlemagazine/noodlemagazine.com/watch/-123_456"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_listing_without_detail_page_requests() {
|
||||
let provider = NoodlemagazineProvider::new();
|
||||
let options = options();
|
||||
let html = r#"
|
||||
<div class="list_videos" id="list_videos">
|
||||
<div class="item">
|
||||
<a href="/watch/-123_456">
|
||||
<img data-src="https://noodlemagazine.com/thumbs/test.jpg" />
|
||||
</a>
|
||||
<div class="title">sample & title</div>
|
||||
<svg><use></use></svg>#clock-o"></use></svg>12:34<
|
||||
<svg><use></use></svg>#eye"></use></svg>1.2K<
|
||||
</div>
|
||||
>Show more</div>
|
||||
"#;
|
||||
|
||||
let items = provider.get_video_items_from_html(html.to_string(), &options);
|
||||
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(
|
||||
items[0].url,
|
||||
"https://example.com/proxy/noodlemagazine/noodlemagazine.com/watch/-123_456"
|
||||
);
|
||||
assert_eq!(
|
||||
items[0].thumb,
|
||||
"https://noodlemagazine.com/thumbs/test.jpg"
|
||||
);
|
||||
assert_eq!(items[0].formats.as_ref().map(|f| f.len()), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_https_cdn_thumbs_but_drops_non_images() {
|
||||
let provider = NoodlemagazineProvider::new();
|
||||
let options = options();
|
||||
let html = r#"
|
||||
<div class="list_videos" id="list_videos">
|
||||
<div class="item">
|
||||
<a href="/watch/-123_456">
|
||||
<img data-src="https://cdn.example/thumb.jpg" />
|
||||
</a>
|
||||
<div class="title">sample</div>
|
||||
<svg><use></use></svg>#clock-o"></use></svg>12:34<
|
||||
<svg><use></use></svg>#eye"></use></svg>1.2K<
|
||||
</div>
|
||||
<div class="item">
|
||||
<a href="/watch/-555_666">
|
||||
<img data-src="https://noodlemagazine.com/watch/not-an-image" />
|
||||
</a>
|
||||
<div class="title">sample 2</div>
|
||||
<svg><use></use></svg>#clock-o"></use></svg>00:42<
|
||||
<svg><use></use></svg>#eye"></use></svg>123<
|
||||
</div>
|
||||
>Show more</div>
|
||||
"#;
|
||||
|
||||
let items = provider.get_video_items_from_html(html.to_string(), &options);
|
||||
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(
|
||||
items[0].thumb,
|
||||
"https://cdn.example/thumb.jpg"
|
||||
);
|
||||
assert!(items[1].thumb.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_preview_urls_without_file_extension() {
|
||||
let provider = NoodlemagazineProvider::new();
|
||||
let options = options();
|
||||
let html = r#"
|
||||
<div class="list_videos" id="list_videos">
|
||||
<div class="item">
|
||||
<a href="/watch/-111_222">
|
||||
<img data-src="https://img.pvvstream.pro/preview/abc/-111_222/240/iv.okcdn.ru/getVideoPreview?id=1&type=39&fn=vid_l" />
|
||||
</a>
|
||||
<div class="title">sample</div>
|
||||
<svg><use></use></svg>#clock-o"></use></svg>12:34<
|
||||
<svg><use></use></svg>#eye"></use></svg>1.2K<
|
||||
</div>
|
||||
>Show more</div>
|
||||
"#;
|
||||
|
||||
let items = provider.get_video_items_from_html(html.to_string(), &options);
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(
|
||||
items[0].thumb,
|
||||
"https://example.com/proxy/noodlemagazine-thumb/img.pvvstream.pro/preview/abc/-111_222/240/iv.okcdn.ru/getVideoPreview?id=1&type=39&fn=vid_l"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_item_variants_and_alternate_thumb_attributes() {
|
||||
let provider = NoodlemagazineProvider::new();
|
||||
let options = options();
|
||||
let html = r#"
|
||||
<div class="list_videos" id="list_videos">
|
||||
<div class="item has-video" data-id="123">
|
||||
<a href="/watch/-333_444">
|
||||
<img data-original="https://cdn2.pvvstream.pro/videos/-333/444/preview_320.jpg" />
|
||||
</a>
|
||||
<div class="title">sample alt</div>
|
||||
<svg><use></use></svg>#clock-o"></use></svg>00:42<
|
||||
<svg><use></use></svg>#eye"></use></svg>123<
|
||||
</div>
|
||||
>Show more</div>
|
||||
"#;
|
||||
|
||||
let items = provider.get_video_items_from_html(html.to_string(), &options);
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(
|
||||
items[0].thumb,
|
||||
"https://cdn2.pvvstream.pro/videos/-333/444/preview_320.jpg"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_popular_filters_for_usability_options() {
|
||||
let mut options = options();
|
||||
options.category = Some("month".to_string());
|
||||
options.sort = Some("date".to_string());
|
||||
options.filter = Some("asc".to_string());
|
||||
|
||||
assert_eq!(NoodlemagazineProvider::resolve_popular_period(&options), "month");
|
||||
assert_eq!(NoodlemagazineProvider::resolve_sort_by("views", &options), "date");
|
||||
assert_eq!(NoodlemagazineProvider::resolve_sort_order(&options), "asc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_legacy_all_time_period_to_recent_feed() {
|
||||
let mut options = options();
|
||||
options.category = Some("all".to_string());
|
||||
options.sort = Some("views".to_string());
|
||||
options.filter = Some("desc".to_string());
|
||||
|
||||
assert_eq!(NoodlemagazineProvider::resolve_popular_period(&options), "recent");
|
||||
}
|
||||
}
|
||||
401
src/providers/okporn.rs
Normal file
@@ -0,0 +1,401 @@
|
||||
use crate::DbPool;
|
||||
use crate::api::ClientVersion;
|
||||
use crate::providers::{Provider, report_provider_error};
|
||||
use crate::status::*;
|
||||
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;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "mainstream-tube",
|
||||
tags: &["tube", "hd", "mixed"],
|
||||
};
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
|
||||
Channel {
|
||||
id: "okporn".to_string(),
|
||||
name: "Ok.porn".to_string(),
|
||||
description: "Tons of HD porno movies".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=ok.porn".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".to_string(),
|
||||
title: "New".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "popular".to_string(),
|
||||
title: "Popular".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "trending".to_string(),
|
||||
title: "Trending".to_string(),
|
||||
},
|
||||
],
|
||||
multiSelect: false,
|
||||
}],
|
||||
nsfw: true,
|
||||
cacheDuration: Some(1800),
|
||||
}
|
||||
}
|
||||
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() {
|
||||
let location = match response
|
||||
.headers()
|
||||
.get("Location")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
{
|
||||
Some(location) => location,
|
||||
None => {
|
||||
report_provider_error(
|
||||
"okporn",
|
||||
"get.redirect_location",
|
||||
&format!("url={video_url}; missing/invalid Location header"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
response = client
|
||||
.get(self.url.clone() + location)
|
||||
// .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 = match env::var("FLARE_URL") {
|
||||
Ok(url) => url,
|
||||
Err(e) => {
|
||||
report_provider_error("okporn", "get.flare_url", &e.to_string()).await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
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() {
|
||||
let location = match response
|
||||
.headers()
|
||||
.get("Location")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
{
|
||||
Some(location) => location,
|
||||
None => {
|
||||
report_provider_error(
|
||||
"okporn",
|
||||
"query.redirect_location",
|
||||
&format!("url={video_url}; missing/invalid Location header"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
response = client
|
||||
.get(self.url.clone() + location)
|
||||
// .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 = match env::var("FLARE_URL") {
|
||||
Ok(url) => url,
|
||||
Err(e) => {
|
||||
report_provider_error("okporn", "query.flare_url", &e.to_string()).await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
);
|
||||
let mut title = video_segment
|
||||
.split("\" title=\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
// html decode
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
let id = video_url
|
||||
.split("/")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(4)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let raw_duration = video_segment
|
||||
.split("<span class=\"duration_item\">")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("data-original=\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
||||
Some(self.build_channel(clientversion))
|
||||
}
|
||||
}
|
||||
503
src/providers/okxxx.rs
Normal file
@@ -0,0 +1,503 @@
|
||||
use crate::DbPool;
|
||||
use crate::api::ClientVersion;
|
||||
use crate::providers::{Provider, report_provider_error};
|
||||
use crate::status::*;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::flaresolverr::{FlareSolverrRequest, Flaresolverr};
|
||||
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::env;
|
||||
use std::vec;
|
||||
use wreq::Client;
|
||||
use wreq_util::Emulation;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "mainstream-tube",
|
||||
tags: &["tube", "mixed", "search"],
|
||||
};
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
|
||||
Channel {
|
||||
id: "okxxx".to_string(),
|
||||
name: "Ok.xxx".to_string(),
|
||||
description: "free porn tube!".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=ok.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: "new".to_string(),
|
||||
title: "New".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "popular".to_string(),
|
||||
title: "Popular".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "trending".to_string(),
|
||||
title: "Trending".to_string(),
|
||||
},
|
||||
],
|
||||
multiSelect: false,
|
||||
}],
|
||||
nsfw: true,
|
||||
cacheDuration: Some(1800),
|
||||
}
|
||||
}
|
||||
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() {
|
||||
let location = match response
|
||||
.headers()
|
||||
.get("Location")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
{
|
||||
Some(location) => location,
|
||||
None => {
|
||||
report_provider_error(
|
||||
"okxxx",
|
||||
"get.redirect_location",
|
||||
&format!("url={video_url}; missing/invalid Location header"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
println!("Redirection detected, following to: {}", location);
|
||||
response = client
|
||||
.get(location)
|
||||
// .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 = match env::var("FLARE_URL") {
|
||||
Ok(url) => url,
|
||||
Err(e) => {
|
||||
report_provider_error("okxxx", "get.flare_url", &e.to_string()).await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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() {
|
||||
let location = match response
|
||||
.headers()
|
||||
.get("Location")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
{
|
||||
Some(location) => location,
|
||||
None => {
|
||||
report_provider_error(
|
||||
"okxxx",
|
||||
"query.redirect_location",
|
||||
&format!("url={video_url}; missing/invalid Location header"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
response = client
|
||||
.get(self.url.clone() + location)
|
||||
// .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 = match env::var("FLARE_URL") {
|
||||
Ok(url) => url,
|
||||
Err(e) => {
|
||||
report_provider_error("okxxx", "query.flare_url", &e.to_string()).await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
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>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
);
|
||||
let preview_url = video_segment
|
||||
.split("data-preview-custom=\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let mut title = video_segment
|
||||
.split("\" title=\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
// html decode
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
let id = video_url
|
||||
.split("/")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(4)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let raw_duration = video_segment
|
||||
.split("fa fa-clock-o")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<span>")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("data-original=\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<span>")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
||||
Some(self.build_channel(clientversion))
|
||||
}
|
||||
}
|
||||
1533
src/providers/omgxxx.rs
Normal file
266
src/providers/paradisehill.rs
Normal file
@@ -0,0 +1,266 @@
|
||||
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::videos::ServerOptions;
|
||||
use crate::videos::VideoItem;
|
||||
use async_trait::async_trait;
|
||||
use error_chain::error_chain;
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "mainstream-tube",
|
||||
tags: &["tube", "mixed", "movies"],
|
||||
};
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
|
||||
Channel {
|
||||
id: "paradisehill".to_string(),
|
||||
name: "Paradisehill".to_string(),
|
||||
description: "Porn Movies on Paradise Hill".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=en.paradisehill.cc"
|
||||
.to_string(),
|
||||
status: "active".to_string(),
|
||||
categories: vec![],
|
||||
options: vec![],
|
||||
nsfw: true,
|
||||
cacheDuration: None,
|
||||
}
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let mut requester =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
|
||||
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 = match requester.get(&url_str, None).await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
crate::providers::report_provider_error(
|
||||
"paradisehill",
|
||||
"get.request",
|
||||
&format!("url={url_str}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
// 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 =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
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 = match requester.get(&url_str, None).await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
crate::providers::report_provider_error(
|
||||
"paradisehill",
|
||||
"query.request",
|
||||
&format!("url={url_str}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
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 mut items: Vec<VideoItem> = Vec::new();
|
||||
for video_segment in html.split("item list-film-item").skip(1) {
|
||||
let href = video_segment
|
||||
.split("<a href=\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
.unwrap_or_default();
|
||||
if href.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let video_url = format!("{}{}", self.url, href);
|
||||
let id = href
|
||||
.trim_matches('/')
|
||||
.split('/')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
if id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut title = video_segment
|
||||
.split("itemprop=\"name\">")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('<').next())
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
|
||||
let mut thumb = video_segment
|
||||
.split("itemprop=\"image\" src=\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
if thumb.starts_with('/') {
|
||||
thumb = format!("{}{}", self.url, thumb);
|
||||
}
|
||||
|
||||
let genre = video_segment
|
||||
.split("itemprop=\"genre\">")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('<').next())
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
let tags = if genre.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
vec![genre]
|
||||
};
|
||||
|
||||
items.push(
|
||||
VideoItem::new(id, title, video_url, "paradisehill".to_string(), thumb, 0)
|
||||
.aspect_ratio(0.697674419 as f32)
|
||||
.tags(tags),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
#[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![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
||||
Some(self.build_channel(clientversion))
|
||||
}
|
||||
}
|
||||
505
src/providers/perfectgirls.rs
Normal file
@@ -0,0 +1,505 @@
|
||||
use crate::DbPool;
|
||||
use crate::api::ClientVersion;
|
||||
use crate::providers::{Provider, report_provider_error};
|
||||
use crate::status::*;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::flaresolverr::{FlareSolverrRequest, Flaresolverr};
|
||||
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::env;
|
||||
use std::vec;
|
||||
use wreq::Client;
|
||||
use wreq_util::Emulation;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "studio-network",
|
||||
tags: &["glamour", "softcore", "solo"],
|
||||
};
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
|
||||
Channel {
|
||||
id: "perfectgirls".to_string(),
|
||||
name: "Perfectgirls".to_string(),
|
||||
description: "Perfect Girls Tube".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=perfectgirls.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: "new".to_string(),
|
||||
title: "New".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "popular".to_string(),
|
||||
title: "Popular".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "trending".to_string(),
|
||||
title: "Trending".to_string(),
|
||||
},
|
||||
],
|
||||
multiSelect: false,
|
||||
}],
|
||||
nsfw: true,
|
||||
cacheDuration: Some(1800),
|
||||
}
|
||||
}
|
||||
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() {
|
||||
let location = match response
|
||||
.headers()
|
||||
.get("Location")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
{
|
||||
Some(location) => location,
|
||||
None => {
|
||||
report_provider_error(
|
||||
"perfectgirls",
|
||||
"get.redirect_location",
|
||||
&format!("url={video_url}; missing/invalid Location header"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
println!("Redirection detected, following to: {}", location);
|
||||
response = client
|
||||
.get(location)
|
||||
// .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 = match env::var("FLARE_URL") {
|
||||
Ok(url) => url,
|
||||
Err(e) => {
|
||||
report_provider_error("perfectgirls", "get.flare_url", &e.to_string()).await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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() {
|
||||
let location = match response
|
||||
.headers()
|
||||
.get("Location")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
{
|
||||
Some(location) => location,
|
||||
None => {
|
||||
report_provider_error(
|
||||
"perfectgirls",
|
||||
"query.redirect_location",
|
||||
&format!("url={video_url}; missing/invalid Location header"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
response = client
|
||||
.get(self.url.clone() + location)
|
||||
// .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 = match env::var("FLARE_URL") {
|
||||
Ok(url) => url,
|
||||
Err(e) => {
|
||||
report_provider_error("perfectgirls", "query.flare_url", &e.to_string()).await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
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>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
);
|
||||
let preview_url = video_segment
|
||||
.split("data-preview-custom=\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let mut title = video_segment
|
||||
.split("\" title=\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
// html decode
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
let id = video_url
|
||||
.split("/")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(4)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let raw_duration = video_segment
|
||||
.split("fa fa-clock-o")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<span>")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("data-original=\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<span>")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
||||
Some(self.build_channel(clientversion))
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,43 @@
|
||||
use std::vec;
|
||||
|
||||
use error_chain::error_chain;
|
||||
use htmlentity::entity::{decode, encode, CharacterSet, EncodeType, ICodedDataTrait};
|
||||
use htmlentity::types::{AnyhowResult, Byte};
|
||||
|
||||
use crate::providers::Provider;
|
||||
use crate::DbPool;
|
||||
use crate::api::ClientVersion;
|
||||
use crate::db;
|
||||
use crate::providers::{Provider, report_provider_error, report_provider_error_background};
|
||||
use crate::status::*;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::time::parse_time_to_seconds;
|
||||
use crate::videos::{self, PageInfo, Video_Embed, Video_Item, Videos}; // Make sure Provider trait is imported
|
||||
use crate::videos::ServerOptions;
|
||||
use crate::videos::{self, 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::Client;
|
||||
use wreq::Version;
|
||||
use wreq_util::Emulation;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "studio-network",
|
||||
tags: &["regional", "amateur", "mixed"],
|
||||
};
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(reqwest::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,
|
||||
}
|
||||
@@ -24,119 +47,383 @@ impl PerverzijaProvider {
|
||||
url: "https://tube.perverzija.com/".to_string(),
|
||||
}
|
||||
}
|
||||
async fn get(&self, page: &u8, featured: String) -> Result<Vec<Video_Item>> {
|
||||
|
||||
fn build_channel(&self, clientversion: ClientVersion) -> Channel {
|
||||
let _ = clientversion;
|
||||
|
||||
Channel {
|
||||
id: "perverzija".to_string(),
|
||||
name: "Perverzija".to_string(),
|
||||
description: "Free videos from Perverzija".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=tube.perverzija.com"
|
||||
.to_string(),
|
||||
status: "active".to_string(),
|
||||
categories: vec![],
|
||||
options: vec![ChannelOption {
|
||||
id: "featured".to_string(),
|
||||
title: "Featured".to_string(),
|
||||
description: "Filter Featured Videos.".to_string(),
|
||||
systemImage: "star".to_string(),
|
||||
colorName: "red".to_string(),
|
||||
options: vec![
|
||||
FilterOption {
|
||||
id: "all".to_string(),
|
||||
title: "No".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "featured".to_string(),
|
||||
title: "Yes".to_string(),
|
||||
},
|
||||
],
|
||||
multiSelect: false,
|
||||
}],
|
||||
nsfw: true,
|
||||
cacheDuration: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_between<'a>(haystack: &'a str, start: &str, end: &str) -> Option<&'a str> {
|
||||
let rest = haystack.split(start).nth(1)?;
|
||||
Some(rest.split(end).next().unwrap_or_default())
|
||||
}
|
||||
|
||||
fn extract_iframe_src(haystack: &str) -> String {
|
||||
Self::extract_between(haystack, "iframe src=\"", "\"")
|
||||
.or_else(|| Self::extract_between(haystack, "iframe src="", """))
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn extract_thumb(haystack: &str) -> String {
|
||||
let img_segment = haystack.split("<img").nth(1).unwrap_or_default();
|
||||
let mut thumb = Self::extract_between(img_segment, "data-original=\"", "\"")
|
||||
.or_else(|| Self::extract_between(img_segment, "data-src=\"", "\""))
|
||||
.or_else(|| Self::extract_between(img_segment, "src=\"", "\""))
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
if thumb.starts_with("data:image") {
|
||||
thumb.clear();
|
||||
} else if thumb.starts_with("//") {
|
||||
thumb = format!("https:{thumb}");
|
||||
}
|
||||
|
||||
thumb
|
||||
}
|
||||
|
||||
fn extract_title(haystack: &str) -> String {
|
||||
let mut title = Self::extract_between(haystack, "<h4 class='gv-title'>", "</h4>")
|
||||
.or_else(|| Self::extract_between(haystack, "<h4 class=\"gv-title\">", "</h4>"))
|
||||
.or_else(|| Self::extract_between(haystack, " title='", "'"))
|
||||
.or_else(|| Self::extract_between(haystack, " title=\"", "\""))
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
|
||||
if title.contains('<') && title.contains('>') {
|
||||
let mut plain = String::new();
|
||||
let mut in_tag = false;
|
||||
for c in title.chars() {
|
||||
match c {
|
||||
'<' => in_tag = true,
|
||||
'>' => in_tag = false,
|
||||
_ if !in_tag => plain.push(c),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let normalized = plain.split_whitespace().collect::<Vec<&str>>().join(" ");
|
||||
if !normalized.is_empty() {
|
||||
title = normalized;
|
||||
}
|
||||
} else {
|
||||
title = title.split_whitespace().collect::<Vec<&str>>().join(" ");
|
||||
}
|
||||
|
||||
title.trim().to_string()
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
pool: DbPool,
|
||||
page: u8,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let featured = options.featured.clone().unwrap_or("".to_string());
|
||||
let mut prefix_uri = "".to_string();
|
||||
if featured == "featured"{
|
||||
if featured == "featured" {
|
||||
prefix_uri = "featured-scenes/".to_string();
|
||||
}
|
||||
let mut url = format!("{}{}page/{}/", self.url, prefix_uri, page);
|
||||
if page == &1 {
|
||||
url = format!("{}{}", self.url, prefix_uri);
|
||||
let mut url_str = format!("{}{}page/{}/", self.url, prefix_uri, page);
|
||||
if page == 1 {
|
||||
url_str = format!("{}{}", self.url, prefix_uri);
|
||||
}
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("Mozilla/5.0 (iPhone; CPU iPhone OS 14_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/33.0 Mobile/15E148 Safari/605.1.15")
|
||||
// .proxy(Proxy::https("http://192.168.0.101:8080").unwrap())
|
||||
// .danger_accept_invalid_certs(true)
|
||||
.build()?;
|
||||
let response = client.get(url).send().await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let text = response.text().await?;
|
||||
let video_items = self.get_video_items_from_html(text.clone());
|
||||
Ok(video_items)
|
||||
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 =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
let text = match requester.get(&url_str, Some(Version::HTTP_2)).await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
report_provider_error(
|
||||
"perverzija",
|
||||
"get.request",
|
||||
&format!("url={url_str}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
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 {
|
||||
Err("Failed to fetch data".into())
|
||||
return Ok(old_items);
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
fn query(&self, query: &str) -> Result<Vec<Video_Item>> {
|
||||
println!("Searching for query: {}", query);
|
||||
let url = format!("{}?s={}", self.url, query);
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let response = client.get(&url).send()?;
|
||||
if response.status().is_success() {
|
||||
let text = response.text().unwrap_or_default();
|
||||
|
||||
println!("{}", &text);
|
||||
Ok(vec![])
|
||||
} else {
|
||||
Err("Failed to fetch data".into())
|
||||
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 =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
let text = match requester.get(&url_str, Some(Version::HTTP_2)).await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
report_provider_error(
|
||||
"perverzija",
|
||||
"query.request",
|
||||
&format!("url={url_str}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
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) -> Vec<Video_Item> {
|
||||
let mut items: Vec<Video_Item> = Vec::new();
|
||||
|
||||
let raw_html = html.split("video-listing-content").collect::<Vec<&str>>();
|
||||
|
||||
let video_listing_content = raw_html[1];
|
||||
let raw_videos = video_listing_content
|
||||
fn get_video_items_from_html(&self, html: String, pool: DbPool) -> Vec<VideoItem> {
|
||||
if html.is_empty() {
|
||||
report_provider_error_background(
|
||||
"perverzija",
|
||||
"get_video_items_from_html.empty_html",
|
||||
"empty html response",
|
||||
);
|
||||
return vec![];
|
||||
}
|
||||
let mut items: Vec<VideoItem> = Vec::new();
|
||||
let video_listing_content = html.split("video-listing-content").nth(1).unwrap_or(&html);
|
||||
let raw_videos: Vec<&str> = video_listing_content
|
||||
.split("video-item post")
|
||||
.collect::<Vec<&str>>()[1..]
|
||||
.to_vec();
|
||||
.skip(1)
|
||||
.collect();
|
||||
|
||||
for video_segment in &raw_videos {
|
||||
if raw_videos.is_empty() {
|
||||
report_provider_error_background(
|
||||
"perverzija",
|
||||
"get_video_items_from_html.no_segments",
|
||||
&format!("html_len={}", html.len()),
|
||||
);
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let vid = video_segment.split("\n").collect::<Vec<&str>>();
|
||||
let mut index = 0;
|
||||
if vid.len() > 10 {
|
||||
for video_segment in raw_videos {
|
||||
let title = Self::extract_title(video_segment);
|
||||
|
||||
let embed_html_raw = Self::extract_between(video_segment, "data-embed='", "'")
|
||||
.or_else(|| Self::extract_between(video_segment, "data-embed=\"", "\""))
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let embed_html = decode(embed_html_raw.as_bytes())
|
||||
.to_string()
|
||||
.unwrap_or(embed_html_raw.clone());
|
||||
|
||||
let mut url_str = Self::extract_iframe_src(&embed_html);
|
||||
if url_str.is_empty() {
|
||||
url_str = Self::extract_iframe_src(video_segment);
|
||||
}
|
||||
if url_str.is_empty() {
|
||||
report_provider_error_background(
|
||||
"perverzija",
|
||||
"get_video_items_from_html.url_missing",
|
||||
"missing iframe src in segment",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
for line in vid.clone(){
|
||||
println!("{}: {}\n\n", index, line);
|
||||
index += 1;
|
||||
url_str = url_str.replace("index.php", "xs1.php");
|
||||
if url_str.starts_with("https://streamtape.com/") {
|
||||
continue; // Skip Streamtape links
|
||||
}
|
||||
|
||||
let mut title = vid[1].split(">").collect::<Vec<&str>>()[1]
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
let id_url = Self::extract_between(video_segment, "data-url='", "'")
|
||||
.or_else(|| Self::extract_between(video_segment, "data-url=\"", "\""))
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
// html decode
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
let url = vid[1].split("iframe src="").collect::<Vec<&str>>()[1]
|
||||
.split(""")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string().replace("index.php", "xs1.php");;
|
||||
let id = url.split("data=").collect::<Vec<&str>>()[1]
|
||||
.split("&")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
|
||||
let mut id = url_str
|
||||
.split("data=")
|
||||
.nth(1)
|
||||
.unwrap_or_default()
|
||||
.split('&')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
if id.is_empty() {
|
||||
id = id_url
|
||||
.trim_end_matches('/')
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
}
|
||||
|
||||
let raw_duration = Self::extract_between(video_segment, "time_dur\">", "<")
|
||||
.or_else(|| Self::extract_between(video_segment, "class=\"time\">", "<"))
|
||||
.unwrap_or("00:00")
|
||||
.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;
|
||||
let thumb = Self::extract_thumb(video_segment);
|
||||
|
||||
let thumb = match vid[4].contains("srcset=") {
|
||||
true => vid[4].split("sizes=").collect::<Vec<&str>>()[1]
|
||||
.split("w, ")
|
||||
.collect::<Vec<&str>>()
|
||||
.last()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.split(" ")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string(),
|
||||
false => vid[4].split("src=\"").collect::<Vec<&str>>()[1]
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()[0]
|
||||
.to_string(),
|
||||
};
|
||||
let mut embed_html = vid[1].split("data-embed='").collect::<Vec<&str>>()[1].split("'").collect::<Vec<&str>>()[0]
|
||||
.to_string();
|
||||
embed_html = embed_html.replace("index.php", "xs1.php");
|
||||
match pool.get() {
|
||||
Ok(mut conn) => {
|
||||
if !id_url.is_empty() {
|
||||
let _ = db::insert_video(&mut conn, &id_url, &url_str);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
report_provider_error_background(
|
||||
"perverzija",
|
||||
"get_video_items_from_html.insert_video.pool_get",
|
||||
&e.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let referer_url = "https://xtremestream.xyz/".to_string();
|
||||
|
||||
println!("Embed HTML: {}\n\n", embed_html);
|
||||
println!("Url: {}\n\n", url.clone());
|
||||
let embed = Video_Embed::new(embed_html, url.clone());
|
||||
let mut video_item =
|
||||
Video_Item::new(id, title, url.clone(), "perverzija".to_string(), thumb, duration);
|
||||
video_item.embed = Some(embed);
|
||||
let mut format = videos::Video_Format::new(url.clone(), "1080".to_string(), "m3u8".to_string());
|
||||
format.add_http_header("Referer".to_string(), url.clone().replace("xs1.php", "index.php"));
|
||||
let mut tags: Vec<String> = Vec::new();
|
||||
|
||||
let studios_parts = video_segment.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>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.replace("https://tube.perverzija.com/studio/", "@studio:")
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for tag in video_segment.split_whitespace() {
|
||||
let token =
|
||||
tag.trim_matches(|c: char| c == '"' || c == '\'' || c == '>' || c == '<');
|
||||
if token.starts_with("stars-") {
|
||||
let tag_name = token
|
||||
.split("stars-")
|
||||
.nth(1)
|
||||
.unwrap_or_default()
|
||||
.split('"')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
if !tag_name.is_empty() {
|
||||
tags.push(format!("@stars:{}", tag_name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for tag in video_segment.split_whitespace() {
|
||||
let token =
|
||||
tag.trim_matches(|c: char| c == '"' || c == '\'' || c == '>' || c == '<');
|
||||
if token.starts_with("tag-") {
|
||||
let tag_name = token.split("tag-").nth(1).unwrap_or_default().to_string();
|
||||
if !tag_name.is_empty() {
|
||||
tags.push(tag_name.replace("-", " ").to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
@@ -147,21 +434,338 @@ impl PerverzijaProvider {
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
async fn get_video_items_from_html_query(&self, html: String, pool: DbPool) -> Vec<VideoItem> {
|
||||
let raw_videos: Vec<&str> = html.split("video-item post").skip(1).collect();
|
||||
if raw_videos.is_empty() {
|
||||
report_provider_error_background(
|
||||
"perverzija",
|
||||
"get_video_items_from_html_query.no_segments",
|
||||
&format!("html_len={}", html.len()),
|
||||
);
|
||||
return 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> {
|
||||
if snippet.trim().is_empty() {
|
||||
report_provider_error_background(
|
||||
"perverzija",
|
||||
"get_video_item.empty_snippet",
|
||||
"snippet is empty",
|
||||
);
|
||||
return Err("empty snippet".into());
|
||||
}
|
||||
|
||||
let title = Self::extract_title(snippet);
|
||||
|
||||
let thumb = Self::extract_thumb(snippet);
|
||||
let duration = 0;
|
||||
|
||||
let lookup_url = Self::extract_between(snippet, " href=\"", "\"")
|
||||
.or_else(|| Self::extract_between(snippet, "data-url='", "'"))
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
if lookup_url.is_empty() {
|
||||
report_provider_error_background(
|
||||
"perverzija",
|
||||
"get_video_item.lookup_url_missing",
|
||||
"missing lookup url in snippet",
|
||||
);
|
||||
return Err("Failed to parse lookup url".into());
|
||||
}
|
||||
let referer_url = "https://xtremestream.xyz/".to_string();
|
||||
|
||||
let mut conn = match pool.get() {
|
||||
Ok(conn) => conn,
|
||||
Err(e) => {
|
||||
report_provider_error("perverzija", "get_video_item.pool_get", &e.to_string())
|
||||
.await;
|
||||
return Err("couldn't get db connection from pool".into());
|
||||
}
|
||||
};
|
||||
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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
if id.contains("&") {
|
||||
id = id
|
||||
.split("&")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("</div>")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("</div>")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.replace("https://tube.perverzija.com/stars/", "@stars:")
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tags_parts: Vec<&str> = text
|
||||
.split("<strong>Tags: </strong>")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("</div>")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.replace("https://tube.perverzija.com/stars/", "@stars:")
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let perverzija_db_entry = PerverzijaDbEntry {
|
||||
url_string: url_str.clone(),
|
||||
tags_strings: tags.clone(),
|
||||
};
|
||||
match pool.get() {
|
||||
Ok(mut conn) => {
|
||||
let insert_result = db::insert_video(
|
||||
&mut conn,
|
||||
&lookup_url,
|
||||
&serde_json::to_string(&perverzija_db_entry)?,
|
||||
);
|
||||
if let Err(e) = insert_result {
|
||||
report_provider_error(
|
||||
"perverzija",
|
||||
"get_video_item.insert_video",
|
||||
&e.to_string(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
report_provider_error(
|
||||
"perverzija",
|
||||
"get_video_item.insert_video.pool_get",
|
||||
&e.to_string(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
if id.contains("&") {
|
||||
id = id
|
||||
.split("&")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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,
|
||||
_channel: String,
|
||||
cache: VideoCache,
|
||||
pool: DbPool,
|
||||
sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
per_page: String,
|
||||
featured: String,
|
||||
) -> Vec<Video_Item> {
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let _ = per_page;
|
||||
let _ = sort;
|
||||
let videos: std::result::Result<Vec<Video_Item>, Error> = match query {
|
||||
Some(q) => self.query(&q),
|
||||
None => self.get(&page.parse::<u8>().unwrap_or(1), featured).await,
|
||||
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,
|
||||
@@ -171,4 +775,8 @@ impl Provider for PerverzijaProvider {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
||||
Some(self.build_channel(clientversion))
|
||||
}
|
||||
}
|
||||
|
||||
1081
src/providers/pimpbunny.rs
Normal file
487
src/providers/pmvhaven.rs
Normal file
@@ -0,0 +1,487 @@
|
||||
use crate::DbPool;
|
||||
use crate::api::ClientVersion;
|
||||
use crate::providers::{Provider, report_provider_error_background, requester_or_default};
|
||||
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, VideoFormat, VideoItem};
|
||||
use async_trait::async_trait;
|
||||
use error_chain::error_chain;
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
use std::fmt::Write;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::vec;
|
||||
use url::form_urlencoded::Serializer;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "pmv-compilation",
|
||||
tags: &["pmv", "music", "compilation"],
|
||||
};
|
||||
|
||||
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 encode_query_value(value: &str) -> String {
|
||||
let mut serializer = Serializer::new(String::new());
|
||||
serializer.append_pair("v", value);
|
||||
let encoded = serializer.finish();
|
||||
encoded.strip_prefix("v=").unwrap_or(&encoded).to_string()
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_direct_media_url(url: &str) -> bool {
|
||||
let lower = url.to_ascii_lowercase();
|
||||
(lower.starts_with("http://") || lower.starts_with("https://"))
|
||||
&& (lower.contains("/videos/") || lower.contains(".mp4") || lower.contains(".m3u8"))
|
||||
}
|
||||
|
||||
fn pick_downloadable_media_url(&self, video: &serde_json::Value) -> Option<String> {
|
||||
let video_url = video
|
||||
.get("videoUrl")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
if Self::is_direct_media_url(video_url) {
|
||||
return Some(video_url.replace(' ', "%20"));
|
||||
}
|
||||
|
||||
// Fallback: derive direct media URL from object key.
|
||||
let key = video
|
||||
.get("key")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim_matches('/');
|
||||
if !key.is_empty() {
|
||||
let rebuilt = format!("https://video.pmvhaven.com/{key}");
|
||||
if Self::is_direct_media_url(&rebuilt) {
|
||||
return Some(rebuilt.replace(' ', "%20"));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
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 encoded_search = Self::encode_query_value(&search);
|
||||
|
||||
let mut extra_filters = String::new();
|
||||
if let Ok(stars) = self.stars.read() {
|
||||
if let Some(star) = stars.iter().find(|s| s.eq_ignore_ascii_case(&search)) {
|
||||
let encoded_star = Self::encode_query_value(star);
|
||||
extra_filters.push_str(&format!("&stars={encoded_star}"));
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(cats) = self.categories.read() {
|
||||
if let Some(cat) = cats.iter().find(|c| c.eq_ignore_ascii_case(&search)) {
|
||||
let encoded_cat = Self::encode_query_value(cat);
|
||||
extra_filters.push_str(&format!("&tagMode=OR&tags={encoded_cat}&expandTags=false"));
|
||||
}
|
||||
}
|
||||
|
||||
let mut urls = vec![];
|
||||
if search.is_empty() {
|
||||
urls.push(format!(
|
||||
"{}/api/videos?limit=100&page={page}{duration}{sort}{extra_filters}",
|
||||
self.url
|
||||
));
|
||||
} else {
|
||||
urls.push(format!(
|
||||
"{}/api/videos/search?limit=100&page={page}{duration}{sort}{extra_filters}&q={encoded_search}",
|
||||
self.url
|
||||
));
|
||||
urls.push(format!(
|
||||
"{}/api/videos/search?limit=100&page={page}{duration}{sort}{extra_filters}&query={encoded_search}",
|
||||
self.url
|
||||
));
|
||||
}
|
||||
|
||||
let mut requester = requester_or_default(&options, "pmvhaven", "query");
|
||||
for url in urls {
|
||||
if let Some((time, items)) = cache.get(&url) {
|
||||
if time.elapsed().unwrap_or_default().as_secs() < 300 {
|
||||
return Ok(items.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let text = match requester.get(&url, None).await {
|
||||
Ok(text) => text,
|
||||
Err(err) => {
|
||||
report_provider_error_background(
|
||||
"pmvhaven",
|
||||
"get.request",
|
||||
&format!("url={url}; error={err}"),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let json: serde_json::Value = match serde_json::from_str(&text) {
|
||||
Ok(json) => json,
|
||||
Err(err) => {
|
||||
report_provider_error_background(
|
||||
"pmvhaven",
|
||||
"parse.json",
|
||||
&format!("url={url}; error={err}"),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let items = self.get_video_items_from_json(json).await;
|
||||
|
||||
if !items.is_empty() {
|
||||
cache.remove(&url);
|
||||
cache.insert(url, items.clone());
|
||||
return Ok(items);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
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())
|
||||
.or_else(|| json.get("videos").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 = match self.pick_downloadable_media_url(&video) {
|
||||
Some(url) => url,
|
||||
None => {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
let format_type = if video_url.to_ascii_lowercase().contains(".m3u8") {
|
||||
"m3u8".to_string()
|
||||
} else {
|
||||
"mp4".to_string()
|
||||
};
|
||||
items.push(
|
||||
VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
video_url.clone(),
|
||||
"pmvhaven".into(),
|
||||
thumb,
|
||||
duration as u32,
|
||||
)
|
||||
.views(views as u32)
|
||||
.formats(vec![VideoFormat::new(
|
||||
video_url,
|
||||
"1080".to_string(),
|
||||
format_type,
|
||||
)])
|
||||
.preview(preview),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::PmvhavenProvider;
|
||||
use serde_json::json;
|
||||
|
||||
#[tokio::test]
|
||||
async fn parses_videos_from_videos_key() {
|
||||
let provider = PmvhavenProvider::new();
|
||||
let payload = json!({
|
||||
"success": true,
|
||||
"videos": [{
|
||||
"_id": "abc123",
|
||||
"title": "Sample Title",
|
||||
"videoUrl": "https://video.pmvhaven.com/videos/sample.mp4",
|
||||
"thumbnailUrl": "https://video.pmvhaven.com/thumbnails/sample.webp",
|
||||
"previewUrl": "https://video.pmvhaven.com/previews/sample.mp4",
|
||||
"views": 42,
|
||||
"duration": "2:11",
|
||||
"tags": [],
|
||||
"starsTags": []
|
||||
}]
|
||||
});
|
||||
|
||||
let items = provider.get_video_items_from_json(payload).await;
|
||||
assert_eq!(items.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parses_videos_from_data_key() {
|
||||
let provider = PmvhavenProvider::new();
|
||||
let payload = json!({
|
||||
"success": true,
|
||||
"data": [{
|
||||
"_id": "abc123",
|
||||
"title": "Sample Title",
|
||||
"videoUrl": "https://video.pmvhaven.com/videos/sample.mp4",
|
||||
"thumbnailUrl": "https://video.pmvhaven.com/thumbnails/sample.webp",
|
||||
"previewUrl": "https://video.pmvhaven.com/previews/sample.mp4",
|
||||
"views": 42,
|
||||
"duration": "2:11",
|
||||
"tags": [],
|
||||
"starsTags": []
|
||||
}]
|
||||
});
|
||||
|
||||
let items = provider.get_video_items_from_json(payload).await;
|
||||
assert_eq!(items.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[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))
|
||||
}
|
||||
}
|
||||
334
src/providers/porn00.rs
Normal file
@@ -0,0 +1,334 @@
|
||||
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::vec;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "mainstream-tube",
|
||||
tags: &["tube", "hd", "mixed"],
|
||||
};
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
|
||||
Channel {
|
||||
id: "porn00".to_string(),
|
||||
name: "Porn00".to_string(),
|
||||
description: "HD Porn".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=www.porn00.org".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".to_string(),
|
||||
title: "New".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "popular".to_string(),
|
||||
title: "Popular".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "top-rated".to_string(),
|
||||
title: "Top Rated".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 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 =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
let text = match requester.get(&video_url, None).await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
crate::providers::report_provider_error(
|
||||
"porn00",
|
||||
"get.request",
|
||||
&format!("url={video_url}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
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 =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
let text = match requester.get(&video_url, None).await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
crate::providers::report_provider_error(
|
||||
"porn00",
|
||||
"query.request",
|
||||
&format!("url={video_url}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
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>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let mut title = video_segment
|
||||
.split("\" title=\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
// html decode
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
let id = video_url
|
||||
.split("/")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(4)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let raw_duration = video_segment
|
||||
.split("<div class=\"duration\">")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("data-original=\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
let views_part = video_segment
|
||||
.split("<div class=\"views\">")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
||||
Some(self.build_channel(clientversion))
|
||||
}
|
||||
}
|
||||
650
src/providers/porn4fans.rs
Normal file
@@ -0,0 +1,650 @@
|
||||
use crate::DbPool;
|
||||
use crate::api::ClientVersion;
|
||||
use crate::providers::{Provider, report_provider_error, requester_or_default};
|
||||
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 futures::future::join_all;
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
use regex::Regex;
|
||||
use scraper::{Html, Selector};
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "onlyfans",
|
||||
tags: &["creator", "premium", "clips"],
|
||||
};
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Porn4fansProvider {
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Porn4fansCard {
|
||||
id: String,
|
||||
title: String,
|
||||
page_url: String,
|
||||
thumb: String,
|
||||
duration: u32,
|
||||
views: Option<u32>,
|
||||
rating: Option<f32>,
|
||||
}
|
||||
|
||||
impl Porn4fansProvider {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
url: "https://www.porn4fans.com".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
|
||||
Channel {
|
||||
id: "porn4fans".to_string(),
|
||||
name: "Porn4Fans".to_string(),
|
||||
description: "OnlyFans porn videos.".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=www.porn4fans.com"
|
||||
.to_string(),
|
||||
status: "active".to_string(),
|
||||
categories: vec![],
|
||||
options: vec![],
|
||||
nsfw: true,
|
||||
cacheDuration: Some(1800),
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_by(sort: &str) -> &'static str {
|
||||
match sort {
|
||||
"popular" => "video_viewed",
|
||||
_ => "post_date",
|
||||
}
|
||||
}
|
||||
|
||||
fn build_latest_url(&self, page: u32, sort: &str) -> String {
|
||||
format!(
|
||||
"{}/latest-updates/?mode=async&function=get_block&block_id=custom_list_videos_latest_videos_list&sort_by={}&from={page}",
|
||||
self.url,
|
||||
Self::sort_by(sort)
|
||||
)
|
||||
}
|
||||
|
||||
fn build_latest_headers(&self) -> Vec<(String, String)> {
|
||||
vec![(
|
||||
"Referer".to_string(),
|
||||
format!("{}/latest-updates/", self.url),
|
||||
)]
|
||||
}
|
||||
|
||||
fn build_search_path_query(query: &str, separator: &str) -> String {
|
||||
query.split_whitespace().collect::<Vec<_>>().join(separator)
|
||||
}
|
||||
|
||||
fn build_search_url(&self, query: &str, page: u32, sort: &str) -> String {
|
||||
let query_param = Self::build_search_path_query(query, "+");
|
||||
let path_query = Self::build_search_path_query(query, "-");
|
||||
format!(
|
||||
"{}/search/{path_query}/?mode=async&function=get_block&block_id=custom_list_videos_videos_list_search_result&q={query_param}&sort_by={}&from_videos={page}",
|
||||
self.url,
|
||||
Self::sort_by(sort)
|
||||
)
|
||||
}
|
||||
|
||||
fn build_search_headers(&self, query: &str) -> Vec<(String, String)> {
|
||||
let path_query = Self::build_search_path_query(query, "-");
|
||||
vec![(
|
||||
"Referer".to_string(),
|
||||
format!("{}/search/{path_query}/", self.url),
|
||||
)]
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u32,
|
||||
sort: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let video_url = self.build_latest_url(page, sort);
|
||||
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());
|
||||
}
|
||||
items.clone()
|
||||
}
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
let mut requester =
|
||||
requester_or_default(&options, "porn4fans", "porn4fans.get.missing_requester");
|
||||
let text = match requester
|
||||
.get_with_headers(&video_url, self.build_latest_headers(), None)
|
||||
.await
|
||||
{
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
report_provider_error(
|
||||
"porn4fans",
|
||||
"get.request",
|
||||
&format!("url={video_url}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
|
||||
if text.trim().is_empty() {
|
||||
report_provider_error(
|
||||
"porn4fans",
|
||||
"get.empty_response",
|
||||
&format!("url={video_url}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
|
||||
let video_items = self.get_video_items_from_html(text, requester).await;
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
return Ok(video_items);
|
||||
}
|
||||
|
||||
Ok(old_items)
|
||||
}
|
||||
|
||||
async fn query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u32,
|
||||
query: &str,
|
||||
sort: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let video_url = self.build_search_url(query, page, sort);
|
||||
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());
|
||||
}
|
||||
items.clone()
|
||||
}
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
let mut requester =
|
||||
requester_or_default(&options, "porn4fans", "porn4fans.query.missing_requester");
|
||||
let text = match requester
|
||||
.get_with_headers(&video_url, self.build_search_headers(query), None)
|
||||
.await
|
||||
{
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
report_provider_error(
|
||||
"porn4fans",
|
||||
"query.request",
|
||||
&format!("url={video_url}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
|
||||
if text.trim().is_empty() {
|
||||
report_provider_error(
|
||||
"porn4fans",
|
||||
"query.empty_response",
|
||||
&format!("url={video_url}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
|
||||
let video_items = self.get_video_items_from_html(text, requester).await;
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
return Ok(video_items);
|
||||
}
|
||||
|
||||
Ok(old_items)
|
||||
}
|
||||
|
||||
fn extract_between<'a>(text: &'a str, start: &str, end: &str) -> Option<&'a str> {
|
||||
text.split(start).nth(1)?.split(end).next()
|
||||
}
|
||||
|
||||
fn first_non_empty_attr(segment: &str, attrs: &[&str]) -> Option<String> {
|
||||
attrs.iter().find_map(|attr| {
|
||||
Self::extract_between(segment, attr, "\"")
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_url(&self, url: &str) -> String {
|
||||
if url.starts_with("http://") || url.starts_with("https://") {
|
||||
return url.to_string();
|
||||
}
|
||||
if url.starts_with("//") {
|
||||
return format!("https:{url}");
|
||||
}
|
||||
if url.starts_with('/') {
|
||||
return format!("{}{}", self.url, url);
|
||||
}
|
||||
format!("{}/{}", self.url, url.trim_start_matches("./"))
|
||||
}
|
||||
|
||||
fn extract_thumb_url(&self, segment: &str) -> String {
|
||||
let thumb_raw = Self::first_non_empty_attr(
|
||||
segment,
|
||||
&[
|
||||
"data-original=\"",
|
||||
"data-webp=\"",
|
||||
"srcset=\"",
|
||||
"src=\"",
|
||||
"poster=\"",
|
||||
],
|
||||
)
|
||||
.unwrap_or_default();
|
||||
|
||||
if thumb_raw.starts_with("data:image/") {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
self.normalize_url(&thumb_raw)
|
||||
}
|
||||
|
||||
fn decode_escaped_text(text: &str) -> String {
|
||||
text.replace("\\/", "/").replace("&", "&")
|
||||
}
|
||||
|
||||
fn decode_html_text(text: &str) -> String {
|
||||
decode(text.as_bytes())
|
||||
.to_string()
|
||||
.unwrap_or_else(|_| text.to_string())
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn strip_tags(text: &str) -> String {
|
||||
Regex::new(r"(?is)<[^>]+>")
|
||||
.ok()
|
||||
.map(|regex| regex.replace_all(text, "").to_string())
|
||||
.unwrap_or_else(|| text.to_string())
|
||||
}
|
||||
|
||||
fn push_unique_tag(values: &mut Vec<String>, value: String) {
|
||||
let value = value.trim().to_string();
|
||||
if value.is_empty()
|
||||
|| values
|
||||
.iter()
|
||||
.any(|existing| existing.eq_ignore_ascii_case(&value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
values.push(value);
|
||||
}
|
||||
|
||||
fn extract_views(text: &str) -> Option<u32> {
|
||||
Regex::new(r"(?i)<svg[^>]+icon-eye[^>]*>.*?</svg>\s*<span>([^<]+)</span>")
|
||||
.ok()
|
||||
.and_then(|re| re.captures(text))
|
||||
.and_then(|caps| caps.get(1))
|
||||
.and_then(|m| parse_abbreviated_number(m.as_str().trim()))
|
||||
}
|
||||
|
||||
fn extract_rating(text: &str) -> Option<f32> {
|
||||
Regex::new(r"(?i)<svg[^>]+icon-like[^>]*>.*?</svg>\s*<span>([^<%]+)%</span>")
|
||||
.ok()
|
||||
.and_then(|re| re.captures(text))
|
||||
.and_then(|caps| caps.get(1))
|
||||
.and_then(|m| m.as_str().trim().parse::<f32>().ok())
|
||||
}
|
||||
|
||||
fn extract_direct_video_url_from_page(text: &str) -> Option<String> {
|
||||
let decoded = Self::decode_escaped_text(text);
|
||||
|
||||
for key in ["video_url", "video_alt_url", "contentUrl"] {
|
||||
let pattern = format!(
|
||||
r#"(?is)(?:^|[{{\s,])["']?{}["']?\s*[:=]\s*["'](?P<url>https?://[^"'<>]+?\.mp4)"#,
|
||||
regex::escape(key)
|
||||
);
|
||||
let regex = Regex::new(&pattern).ok()?;
|
||||
if let Some(url) = regex
|
||||
.captures(&decoded)
|
||||
.and_then(|captures| captures.name("url"))
|
||||
.map(|value| value.as_str().to_string())
|
||||
{
|
||||
return Some(url);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn collect_texts(document: &Html, selector: &str) -> Vec<String> {
|
||||
let Ok(selector) = Selector::parse(selector) else {
|
||||
return vec![];
|
||||
};
|
||||
let mut values = Vec::new();
|
||||
for element in document.select(&selector) {
|
||||
let raw_text = element.text().collect::<Vec<_>>().join(" ");
|
||||
let cleaned = Self::decode_html_text(&Self::strip_tags(&raw_text));
|
||||
Self::push_unique_tag(&mut values, cleaned);
|
||||
}
|
||||
|
||||
values
|
||||
}
|
||||
|
||||
fn extract_page_models_and_categories(text: &str) -> (Vec<String>, Vec<String>) {
|
||||
let document = Html::parse_document(text);
|
||||
|
||||
let models = Self::collect_texts(&document, ".player-models-list a[href*=\"/models/\"]");
|
||||
|
||||
let mut categories =
|
||||
Self::collect_texts(&document, ".categories-row a[href*=\"/categories/\"]");
|
||||
for value in Self::collect_texts(&document, ".tags-row a[href*=\"/tags/\"]") {
|
||||
Self::push_unique_tag(&mut categories, value);
|
||||
}
|
||||
|
||||
(models, categories)
|
||||
}
|
||||
|
||||
fn parse_video_cards_from_html(&self, html: &str) -> Vec<Porn4fansCard> {
|
||||
if html.trim().is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let Ok(link_re) = Regex::new(
|
||||
r#"(?is)<a[^>]+class="item-link"[^>]+href="(?P<href>[^"]+/video/(?P<id>\d+)/[^"]+)"[^>]+title="(?P<title>[^"]+)"[^>]*>(?P<body>.*?)</a>"#,
|
||||
) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let mut items = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
for captures in link_re.captures_iter(html) {
|
||||
let Some(id) = captures.name("id").map(|m| m.as_str().to_string()) else {
|
||||
continue;
|
||||
};
|
||||
if !seen.insert(id.clone()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let href = captures
|
||||
.name("href")
|
||||
.map(|m| self.normalize_url(m.as_str()))
|
||||
.unwrap_or_default();
|
||||
let title_raw = captures
|
||||
.name("title")
|
||||
.map(|m| m.as_str())
|
||||
.unwrap_or_default();
|
||||
let title = decode(title_raw.as_bytes())
|
||||
.to_string()
|
||||
.unwrap_or_else(|_| title_raw.to_string());
|
||||
let body = captures
|
||||
.name("body")
|
||||
.map(|m| m.as_str())
|
||||
.unwrap_or_default();
|
||||
let thumb = self.extract_thumb_url(body);
|
||||
let duration_raw = Self::extract_between(body, "<div class=\"duration\">", "<")
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
let duration = parse_time_to_seconds(&duration_raw).unwrap_or(0) as u32;
|
||||
let views = Self::extract_views(body).unwrap_or(0);
|
||||
let rating = Self::extract_rating(body);
|
||||
|
||||
items.push(Porn4fansCard {
|
||||
id,
|
||||
title,
|
||||
page_url: href,
|
||||
thumb,
|
||||
duration,
|
||||
views: (views > 0).then_some(views),
|
||||
rating,
|
||||
});
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
async fn enrich_video_card(
|
||||
&self,
|
||||
card: Porn4fansCard,
|
||||
mut requester: crate::util::requester::Requester,
|
||||
) -> VideoItem {
|
||||
let direct_url = requester
|
||||
.get_with_headers(
|
||||
&card.page_url,
|
||||
vec![("Referer".to_string(), format!("{}/", self.url))],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
let (direct_url, models, categories) = match direct_url {
|
||||
Some(text) => {
|
||||
let url = Self::extract_direct_video_url_from_page(&text)
|
||||
.unwrap_or_else(|| card.page_url.clone());
|
||||
let (models, categories) = Self::extract_page_models_and_categories(&text);
|
||||
(url, models, categories)
|
||||
}
|
||||
None => (card.page_url.clone(), vec![], vec![]),
|
||||
};
|
||||
|
||||
let mut item = VideoItem::new(
|
||||
card.id,
|
||||
card.title,
|
||||
direct_url,
|
||||
"porn4fans".to_string(),
|
||||
card.thumb,
|
||||
card.duration,
|
||||
);
|
||||
if let Some(views) = card.views {
|
||||
item = item.views(views);
|
||||
}
|
||||
if let Some(rating) = card.rating {
|
||||
item = item.rating(rating);
|
||||
}
|
||||
if let Some(model) = models.first() {
|
||||
item = item.uploader(model.clone());
|
||||
}
|
||||
item = item.tags(categories);
|
||||
item
|
||||
}
|
||||
|
||||
async fn get_video_items_from_html(
|
||||
&self,
|
||||
html: String,
|
||||
requester: crate::util::requester::Requester,
|
||||
) -> Vec<VideoItem> {
|
||||
let cards = self.parse_video_cards_from_html(&html);
|
||||
let futures = cards
|
||||
.into_iter()
|
||||
.map(|card| self.enrich_video_card(card, requester.clone()));
|
||||
|
||||
join_all(futures).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for Porn4fansProvider {
|
||||
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::<u32>().unwrap_or(1);
|
||||
|
||||
let videos = match query {
|
||||
Some(query) if !query.trim().is_empty() => {
|
||||
self.query(cache, page, &query, &sort, options).await
|
||||
}
|
||||
_ => self.get(cache, page, &sort, options).await,
|
||||
};
|
||||
|
||||
match videos {
|
||||
Ok(videos) => videos,
|
||||
Err(e) => {
|
||||
report_provider_error(
|
||||
"porn4fans",
|
||||
"get_videos",
|
||||
&format!("page={page}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
||||
Some(self.build_channel(clientversion))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Porn4fansProvider;
|
||||
|
||||
#[test]
|
||||
fn builds_latest_url_with_custom_block_id() {
|
||||
let provider = Porn4fansProvider::new();
|
||||
assert_eq!(
|
||||
provider.build_latest_url(2, "new"),
|
||||
"https://www.porn4fans.com/latest-updates/?mode=async&function=get_block&block_id=custom_list_videos_latest_videos_list&sort_by=post_date&from=2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_search_url_with_custom_block_id() {
|
||||
let provider = Porn4fansProvider::new();
|
||||
assert_eq!(
|
||||
provider.build_search_url("big black cock", 3, "popular"),
|
||||
"https://www.porn4fans.com/search/big-black-cock/?mode=async&function=get_block&block_id=custom_list_videos_videos_list_search_result&q=big+black+cock&sort_by=video_viewed&from_videos=3"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_porn4fans_search_markup() {
|
||||
let provider = Porn4fansProvider::new();
|
||||
let html = r##"
|
||||
<div class="thumbs second grid-1" id="custom_list_videos_videos_list_search_result_items">
|
||||
<div class="item">
|
||||
<a class="item-link" href="https://www.porn4fans.com/video/10194/horny-police-officer-melztube-gets-banged-by-bbc/" title="Horny Police Officer Melztube Gets Banged By BBC">
|
||||
<div class="img-wrap">
|
||||
<div class="duration">23:47</div>
|
||||
<picture>
|
||||
<source srcset="https://www.porn4fans.com/contents/videos_screenshots/10000/10194/800x450/1.jpg" type="image/webp">
|
||||
<img class="thumb lazy-load" src="data:image/gif;base64,AAAA" data-original="https://www.porn4fans.com/contents/videos_screenshots/10000/10194/800x450/1.jpg" data-webp="https://www.porn4fans.com/contents/videos_screenshots/10000/10194/800x450/1.jpg" data-preview="https://www.porn4fans.com/get_file/3/9df8de1fc2da5dfcbf9a4ad512dc8f306c4997e60f/10000/10194/10194_preview_high.mp4/" alt="Horny Police Officer Melztube Gets Banged By BBC" />
|
||||
</picture>
|
||||
</div>
|
||||
<div class="video-text">Horny Police Officer Melztube Gets Banged By BBC</div>
|
||||
<ul class="video-items">
|
||||
<li class="video-item">
|
||||
<svg class="svg-icon icon-eye"><use xlink:href="#icon-eye"></use></svg>
|
||||
<span>14K</span>
|
||||
</li>
|
||||
<li class="video-item rating">
|
||||
<svg class="svg-icon icon-like"><use xlink:href="#icon-like"></use></svg>
|
||||
<span>66%</span>
|
||||
</li>
|
||||
<li class="video-item">
|
||||
<span>2 weeks ago</span>
|
||||
</li>
|
||||
</ul>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
"##;
|
||||
|
||||
let items = provider.parse_video_cards_from_html(html);
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].id, "10194");
|
||||
assert_eq!(
|
||||
items[0].page_url,
|
||||
"https://www.porn4fans.com/video/10194/horny-police-officer-melztube-gets-banged-by-bbc/"
|
||||
);
|
||||
assert_eq!(
|
||||
items[0].thumb,
|
||||
"https://www.porn4fans.com/contents/videos_screenshots/10000/10194/800x450/1.jpg"
|
||||
);
|
||||
assert_eq!(items[0].duration, 1427);
|
||||
assert_eq!(items[0].views, Some(14_000));
|
||||
assert_eq!(items[0].rating, Some(66.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_direct_video_url_from_video_page() {
|
||||
let html = r#"
|
||||
<script>
|
||||
var flashvars = {
|
||||
video_url: 'https:\/\/www.porn4fans.com\/get_file\/3\/9df8de1fc2da5dfcbf9a4ad512dc8f306c4997e60f\/10000\/10951\/10951.mp4\/',
|
||||
video_alt_url: 'https:\/\/www.porn4fans.com\/get_file\/3\/9df8de1fc2da5dfcbf9a4ad512dc8f306c4997e60f\/10000\/10951\/10951_720p.mp4\/'
|
||||
};
|
||||
</script>
|
||||
"#;
|
||||
|
||||
assert_eq!(
|
||||
Porn4fansProvider::extract_direct_video_url_from_page(html).as_deref(),
|
||||
Some(
|
||||
"https://www.porn4fans.com/get_file/3/9df8de1fc2da5dfcbf9a4ad512dc8f306c4997e60f/10000/10951/10951.mp4"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_models_and_categories_from_video_page() {
|
||||
let html = r#"
|
||||
<div class="player-models-list">
|
||||
<div class="player-model-item">
|
||||
<a href="/models/piper-rockelle/"><span class="player-model-name">Piper Rockelle</span></a>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="categories-row">
|
||||
<li class="visible"><a href="/categories/striptease/">Striptease</a></li>
|
||||
<li class="visible"><a href="/categories/teen/">Teen</a></li>
|
||||
</ul>
|
||||
<ul class="tags-row">
|
||||
<li class="visible"><a href="/tags/bathroom/">Bathroom</a></li>
|
||||
</ul>
|
||||
"#;
|
||||
|
||||
let (models, categories) = Porn4fansProvider::extract_page_models_and_categories(html);
|
||||
assert_eq!(models, vec!["Piper Rockelle".to_string()]);
|
||||
assert_eq!(
|
||||
categories,
|
||||
vec![
|
||||
"Striptease".to_string(),
|
||||
"Teen".to_string(),
|
||||
"Bathroom".to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
1482
src/providers/porndish.rs
Normal file
396
src/providers/pornhat.rs
Normal file
@@ -0,0 +1,396 @@
|
||||
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::vec;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "mainstream-tube",
|
||||
tags: &["tube", "hd", "mixed"],
|
||||
};
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
|
||||
Channel {
|
||||
id: "pornhat".to_string(),
|
||||
name: "Pornhat".to_string(),
|
||||
description: "free HD porn videos".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=pornhat.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".to_string(),
|
||||
title: "New".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "popular".to_string(),
|
||||
title: "Popular".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "trending".to_string(),
|
||||
title: "Trending".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 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 =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
let text = match requester.get(&video_url, None).await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
crate::providers::report_provider_error(
|
||||
"pornhat",
|
||||
"get.request",
|
||||
&format!("url={video_url}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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 =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
let text = match requester.get(&video_url, None).await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
crate::providers::report_provider_error(
|
||||
"pornhat",
|
||||
"query.request",
|
||||
&format!("url={video_url}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
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>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
);
|
||||
let preview_url = video_segment
|
||||
.split("data-preview-custom=\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let mut title = video_segment
|
||||
.split("\" title=\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
// html decode
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
let id = video_url
|
||||
.split("/")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(4)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let raw_duration = video_segment
|
||||
.split("fa fa-clock-o")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<span>")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("data-original=\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<span>")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
||||
Some(self.build_channel(clientversion))
|
||||
}
|
||||
}
|
||||
1368
src/providers/pornhd3x.rs
Normal file
353
src/providers/pornhub.rs
Normal file
@@ -0,0 +1,353 @@
|
||||
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::vec;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "mainstream-tube",
|
||||
tags: &["mainstream", "studio", "general"],
|
||||
};
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
|
||||
Channel {
|
||||
id: "pornhub".to_string(),
|
||||
name: "Pornhub".to_string(),
|
||||
description: "Pornhub Free Videos".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=pornhub.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: "mr".to_string(),
|
||||
title: "Most Recent".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "mv".to_string(),
|
||||
title: "Most Viewed".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "tr".to_string(),
|
||||
title: "Top Rated".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "lg".to_string(),
|
||||
title: "Longest".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "cm".to_string(),
|
||||
title: "Newest".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 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 let (Some(kind), Some(name)) = (parts.get(1), parts.get(2)) {
|
||||
(
|
||||
Some(format!("@{}:{}", kind, name.replace('-', " "))),
|
||||
Some((*name).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![]
|
||||
})
|
||||
}
|
||||
|
||||
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
||||
Some(self.build_channel(clientversion))
|
||||
}
|
||||
}
|
||||
1248
src/providers/pornmz.rs
Normal file
275
src/providers/pornzog.rs
Normal file
@@ -0,0 +1,275 @@
|
||||
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::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;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "mainstream-tube",
|
||||
tags: &["tube", "clips", "mixed"],
|
||||
};
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
|
||||
Channel {
|
||||
id: "pornzog".to_string(),
|
||||
name: "Pornzog".to_string(),
|
||||
description: "Watch free porn videos at PornZog Free Porn Clips. More than 1 million videos, watch for free now!".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=pornzog.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: "recent".to_string(),
|
||||
title: "Recent".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "relevance".to_string(),
|
||||
title: "Relevance".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "viewed".to_string(),
|
||||
title: "Most Viewed".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "rated".to_string(),
|
||||
title: "Most Rated".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "longest".to_string(),
|
||||
title: "Longest".to_string(),
|
||||
},
|
||||
],
|
||||
multiSelect: false,
|
||||
}],
|
||||
nsfw: true,
|
||||
cacheDuration: None,
|
||||
}
|
||||
}
|
||||
|
||||
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("&"));
|
||||
|
||||
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![],
|
||||
};
|
||||
|
||||
// SAFE: Check if requester exists instead of unwrap()
|
||||
let mut requester = match options.requester.clone() {
|
||||
Some(r) => r,
|
||||
None => return Ok(old_items),
|
||||
};
|
||||
|
||||
let text = requester
|
||||
.get(&video_url, None)
|
||||
.await
|
||||
.map_err(|e| format!("{}", e))?;
|
||||
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();
|
||||
|
||||
// Helper for safe splitting: returns Option<&str>
|
||||
fn get_part<'a>(input: &'a str, separator: &str, index: usize) -> Option<&'a str> {
|
||||
input.split(separator).nth(index)
|
||||
}
|
||||
|
||||
// Split HTML safely
|
||||
let sections: Vec<&str> = html.split("class=\"paginator\"").collect();
|
||||
let body = match sections.get(0) {
|
||||
Some(s) => s,
|
||||
None => return vec![],
|
||||
};
|
||||
|
||||
let raw_videos: Vec<&str> = body.split("class=\"thumb-video ").skip(1).collect();
|
||||
|
||||
for (idx, video_segment) in raw_videos.iter().enumerate() {
|
||||
// Attempt to parse each item. If one fails, we log it and continue to the next
|
||||
// instead of crashing the whole request.
|
||||
let result: Option<VideoItem> = (|| {
|
||||
let mut video_url = get_part(video_segment, "href=\"", 1)?
|
||||
.split("\"")
|
||||
.next()?
|
||||
.to_string();
|
||||
|
||||
if video_url.starts_with("/") {
|
||||
video_url = format!("{}{}", self.url, video_url);
|
||||
}
|
||||
|
||||
let title_raw = get_part(video_segment, "alt=\"", 1)?.split("\"").next()?;
|
||||
let title = decode(title_raw.as_bytes())
|
||||
.to_string()
|
||||
.unwrap_or(title_raw.to_string());
|
||||
|
||||
// The ID is the 5th element in a "/" split: e.g., "", "video", "123", "title"
|
||||
let id = video_url.split("/").nth(4)?.to_string();
|
||||
|
||||
let thumb = get_part(video_segment, "data-original=\"", 1)?
|
||||
.split("\"")
|
||||
.next()?
|
||||
.to_string();
|
||||
|
||||
let raw_duration = get_part(video_segment, "class=\"duration\">", 1)?
|
||||
.split("<")
|
||||
.next()?;
|
||||
let duration = parse_time_to_seconds(raw_duration).unwrap_or(0) as u32;
|
||||
let tags_section = get_part(video_segment, "class=\"tags\"", 1)?
|
||||
.split("</p>")
|
||||
.next()?;
|
||||
|
||||
let tags = tags_section
|
||||
.split("<a href=\"")
|
||||
.skip(1)
|
||||
.filter_map(|el| {
|
||||
let name = el.split(">").nth(1)?.split("<").next()?;
|
||||
Some(name.to_string())
|
||||
})
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
Some(
|
||||
VideoItem::new(id, title, video_url, "pornzog".to_string(), thumb, duration)
|
||||
.tags(tags),
|
||||
)
|
||||
})();
|
||||
|
||||
match result {
|
||||
Some(item) => items.push(item),
|
||||
None => eprintln!("Warning: Failed to parse video item at index {}", idx),
|
||||
}
|
||||
}
|
||||
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 page_num = page.parse::<u8>().unwrap_or(1);
|
||||
let query_str = query.unwrap_or_default();
|
||||
|
||||
match self.query(cache, page_num, &query_str, sort, options).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("Error fetching videos from Pornzog: {}", e);
|
||||
|
||||
// 1. Create a collection of owned data so we don't hold references to `e`
|
||||
let mut error_reports = Vec::new();
|
||||
|
||||
// Iterating through the error chain to collect data into owned Strings
|
||||
for cause in e.iter().skip(1) {
|
||||
error_reports.push((
|
||||
cause.to_string(), // Title
|
||||
format_error_chain(cause), // Description/Chain
|
||||
format!("caused by: {}", cause), // Message
|
||||
));
|
||||
}
|
||||
|
||||
// 2. Now that we aren't holding any `&dyn StdError`, we can safely .await
|
||||
for (title, chain_str, msg) in error_reports {
|
||||
let _ = send_discord_error_report(
|
||||
title,
|
||||
Some(chain_str),
|
||||
Some("Pornzog Provider"),
|
||||
Some(&msg),
|
||||
file!(),
|
||||
line!(),
|
||||
module_path!(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
||||
Some(self.build_channel(clientversion))
|
||||
}
|
||||
}
|
||||
378
src/providers/redtube.rs
Normal file
@@ -0,0 +1,378 @@
|
||||
use crate::DbPool;
|
||||
use crate::api::ClientVersion;
|
||||
use crate::providers::{Provider, report_provider_error};
|
||||
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 serde_json::Value;
|
||||
use std::vec;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "mainstream-tube",
|
||||
tags: &["mainstream", "legacy", "general"],
|
||||
};
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
|
||||
Channel {
|
||||
id: "redtube".to_string(),
|
||||
name: "Redtube".to_string(),
|
||||
description: "Redtube brings you NEW porn videos every day for free".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=www.redtube.com".to_string(),
|
||||
status: "active".to_string(),
|
||||
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 _ = 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 =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
let text = match requester.get(&video_url, None).await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
report_provider_error(
|
||||
"redtube",
|
||||
"get.request",
|
||||
&format!("url={video_url}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
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 =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
let text = match requester.get(&video_url, None).await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
report_provider_error(
|
||||
"redtube",
|
||||
"query.request",
|
||||
&format!("url={video_url}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("</script>")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
let mut videos: Value = match serde_json::from_str(video_listing_content) {
|
||||
Ok(videos) => videos,
|
||||
Err(e) => {
|
||||
crate::providers::report_provider_error_background(
|
||||
"redtube",
|
||||
"get_video_items_from_html.json_parse",
|
||||
&e.to_string(),
|
||||
);
|
||||
return items;
|
||||
}
|
||||
};
|
||||
let Some(video_list) = videos.as_array_mut() else {
|
||||
crate::providers::report_provider_error_background(
|
||||
"redtube",
|
||||
"get_video_items_from_html.json_not_array",
|
||||
"expected array",
|
||||
);
|
||||
return items;
|
||||
};
|
||||
for vid in video_list {
|
||||
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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let raw_duration = vid["duration"].as_str().unwrap_or("0");
|
||||
let duration = raw_duration
|
||||
.replace("PT", "")
|
||||
.replace("S", "")
|
||||
.parse::<u32>()
|
||||
.unwrap_or(0);
|
||||
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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let video_url = format!("{}/{}", self.url, id);
|
||||
let title = vid
|
||||
.split(" <a title=\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
let thumb = vid
|
||||
.split("<img")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split(" data-src=\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let raw_duration = vid
|
||||
.split("<span class=\"video-properties tm_video_duration\">")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("</span>")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("</span>")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
let views = parse_abbreviated_number(&views_str).unwrap_or(0) as u32;
|
||||
let preview = vid
|
||||
.split("<img")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split(" data-mediabook=\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
||||
Some(self.build_channel(clientversion))
|
||||
}
|
||||
}
|
||||
482
src/providers/rule34gen.rs
Normal file
@@ -0,0 +1,482 @@
|
||||
use crate::DbPool;
|
||||
use crate::api::*;
|
||||
use crate::providers::{Provider, report_provider_error};
|
||||
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::vec;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "ai",
|
||||
tags: &["rule34", "ai-generated", "animation"],
|
||||
};
|
||||
|
||||
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 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 = if page <= 1 {
|
||||
format!("{}/?sort_by={}", self.url, sort)
|
||||
} else {
|
||||
format!("{}/{}/?sort_by={}", self.url, page, sort)
|
||||
};
|
||||
|
||||
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 =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
let text = match requester.get(&url, None).await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
report_provider_error("rule34gen", "get.request", &format!("url={url}; error={e}"))
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
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 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 search_slug = query.replace(" ", "-");
|
||||
let url = if page <= 1 {
|
||||
format!("{}/search/{}/?sort_by={}", self.url, search_slug, sort)
|
||||
} else {
|
||||
format!(
|
||||
"{}/search/{}/{}/?sort_by={}",
|
||||
self.url, search_slug, page, sort
|
||||
)
|
||||
};
|
||||
|
||||
// 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 =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
let text = match requester.get(&url, None).await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
report_provider_error(
|
||||
"rule34gen",
|
||||
"query.request",
|
||||
&format!("url={url}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
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();
|
||||
if html.contains("cards__item") {
|
||||
for video_segment in html.split("<div class=\"cards__item\"").skip(1) {
|
||||
let video_url = video_segment
|
||||
.split("href=\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
if !video_url.contains("/video/") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let id = video_url
|
||||
.split("/video/")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('/').next())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
if id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut title = video_segment
|
||||
.split("title=\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
if title.is_empty() {
|
||||
title = video_segment
|
||||
.split("<span class=\"card__title\">")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('<').next())
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
}
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
|
||||
let thumb = video_segment
|
||||
.split("data-original=\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let preview = video_segment
|
||||
.split("data-preview=\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('"').next())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let raw_duration = video_segment
|
||||
.split("card__label card__label--primary\">")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('<').next())
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
let duration = parse_time_to_seconds(&raw_duration).unwrap_or(0) as u32;
|
||||
|
||||
let views_text = video_segment
|
||||
.split("<small class=\"card__text\">")
|
||||
.nth(2)
|
||||
.and_then(|s| s.split("</small>").next())
|
||||
.unwrap_or_default()
|
||||
.replace("views", "")
|
||||
.replace(' ', "")
|
||||
.trim()
|
||||
.to_string();
|
||||
let views = parse_abbreviated_number(views_text.as_str()).unwrap_or(0) as u32;
|
||||
|
||||
items.push(
|
||||
VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
video_url,
|
||||
"rule34gen".to_string(),
|
||||
thumb,
|
||||
duration,
|
||||
)
|
||||
.views(views)
|
||||
.preview(preview),
|
||||
);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
let video_listing_content = html
|
||||
.split("<div class=\"thumbs clearfix\" id=\"custom_list_videos")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<div class=\"pagination\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>().get(1).copied().unwrap_or_default()
|
||||
// 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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("/")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let raw_duration = video_segment
|
||||
.split("<div class=\"time\">")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("</svg>")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("data-original=\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let url = video_segment
|
||||
.split("<a class=\"th js-open-popup\" href=\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
// let preview = video_segment.split("<div class=\"img wrap_image\" data-preview=\"").collect::<Vec<&str>>().get(1).copied().unwrap_or_default()
|
||||
// .split("\"")
|
||||
// .collect::<Vec<&str>>().get(0).copied().unwrap_or_default()
|
||||
// .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))
|
||||
}
|
||||
}
|
||||
340
src/providers/rule34video.rs
Normal file
@@ -0,0 +1,340 @@
|
||||
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::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;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "hentai-animation",
|
||||
tags: &["rule34", "animation", "fandom"],
|
||||
};
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
|
||||
Channel {
|
||||
id: "rule34video".to_string(),
|
||||
name: "Rule34Video".to_string(),
|
||||
description: "If it exists, there is porn".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=rule34video.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: "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),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
||||
Some(self.build_channel(clientversion))
|
||||
}
|
||||
}
|
||||
1430
src/providers/sextb.rs
Normal file
1436
src/providers/shooshtime.rs
Normal file
997
src/providers/spankbang.rs
Normal file
@@ -0,0 +1,997 @@
|
||||
use crate::DbPool;
|
||||
use crate::api::ClientVersion;
|
||||
use crate::providers::{Provider, report_provider_error, requester_or_default};
|
||||
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, VideoFormat, VideoItem};
|
||||
use async_trait::async_trait;
|
||||
use error_chain::error_chain;
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
use scraper::{ElementRef, Html, Selector};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
use url::form_urlencoded::byte_serialize;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "mainstream-tube",
|
||||
tags: &["mainstream", "mixed", "search"],
|
||||
};
|
||||
|
||||
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 {
|
||||
Self {
|
||||
url: "https://spankbang.com".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
|
||||
Channel {
|
||||
id: "spankbang".to_string(),
|
||||
name: "SpankBang".to_string(),
|
||||
description: "Porn videos, trending searches, and featured scenes.".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=spankbang.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: "trending".to_string(),
|
||||
title: "Trending".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "upcoming".to_string(),
|
||||
title: "Upcoming".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "new".to_string(),
|
||||
title: "New".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "popular".to_string(),
|
||||
title: "Popular".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "featured".to_string(),
|
||||
title: "Featured".to_string(),
|
||||
},
|
||||
],
|
||||
multiSelect: false,
|
||||
}],
|
||||
nsfw: true,
|
||||
cacheDuration: Some(1800),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_get_sort(sort: &str) -> &'static str {
|
||||
match sort {
|
||||
"upcoming" => "upcoming",
|
||||
"new" => "new",
|
||||
"popular" => "popular",
|
||||
_ => "trending",
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_query_sort(sort: &str) -> &'static str {
|
||||
match sort {
|
||||
"new" => "new",
|
||||
"popular" => "popular",
|
||||
"featured" => "featured",
|
||||
_ => "trending",
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_search_query(query: &str) -> String {
|
||||
query
|
||||
.split_whitespace()
|
||||
.map(|part| byte_serialize(part.as_bytes()).collect::<String>())
|
||||
.collect::<Vec<_>>()
|
||||
.join("+")
|
||||
}
|
||||
|
||||
fn build_get_url(&self, page: u32, sort: &str) -> String {
|
||||
match Self::normalize_get_sort(sort) {
|
||||
"upcoming" => {
|
||||
if page > 1 {
|
||||
format!("{}/upcoming/{page}/", self.url)
|
||||
} else {
|
||||
format!("{}/upcoming/", self.url)
|
||||
}
|
||||
}
|
||||
"new" => {
|
||||
if page > 1 {
|
||||
format!("{}/new_videos/{page}/", self.url)
|
||||
} else {
|
||||
format!("{}/new_videos/", self.url)
|
||||
}
|
||||
}
|
||||
"popular" => {
|
||||
if page > 1 {
|
||||
format!("{}/most_popular/{page}/?p=w", self.url)
|
||||
} else {
|
||||
format!("{}/most_popular/?p=w", self.url)
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if page > 1 {
|
||||
format!("{}/trending_videos/{page}/", self.url)
|
||||
} else {
|
||||
format!("{}/trending_videos/", self.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn request_headers(&self) -> Vec<(String, String)> {
|
||||
vec![("Referer".to_string(), format!("{}/", self.url))]
|
||||
}
|
||||
|
||||
fn is_cloudflare_block(text: &str) -> bool {
|
||||
let lowercase = text.to_ascii_lowercase();
|
||||
lowercase.contains("attention required")
|
||||
|| lowercase.contains("you have been blocked")
|
||||
|| lowercase.contains("cloudflare ray id")
|
||||
}
|
||||
|
||||
fn fallback_items_from_ytdlp(&self, page_url: &str, limit: usize) -> Vec<VideoItem> {
|
||||
let output = match Command::new("yt-dlp")
|
||||
.arg("-J")
|
||||
.arg("--flat-playlist")
|
||||
.arg("--extractor-args")
|
||||
.arg("generic:impersonate=chrome")
|
||||
.arg(page_url)
|
||||
.output()
|
||||
{
|
||||
Ok(output) if output.status.success() => output,
|
||||
_ => return vec![],
|
||||
};
|
||||
|
||||
let payload: serde_json::Value = match serde_json::from_slice(&output.stdout) {
|
||||
Ok(payload) => payload,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
|
||||
let entries = match payload.get("entries").and_then(|value| value.as_array()) {
|
||||
Some(entries) => entries,
|
||||
None => return vec![],
|
||||
};
|
||||
|
||||
let mut items = Vec::new();
|
||||
for (index, entry) in entries.iter().take(limit).enumerate() {
|
||||
let Some(url) = entry.get("url").and_then(|value| value.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
if !(url.starts_with("https://") || url.starts_with("http://")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let id = entry
|
||||
.get("id")
|
||||
.and_then(|value| value.as_str())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| format!("spankbang-fallback-{}", index + 1));
|
||||
let title = entry
|
||||
.get("title")
|
||||
.and_then(|value| value.as_str())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(Self::decode_html)
|
||||
.unwrap_or_else(|| format!("SpankBang Video {}", index + 1));
|
||||
let thumb = entry
|
||||
.get("thumbnail")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let duration = entry
|
||||
.get("duration")
|
||||
.and_then(|value| value.as_u64())
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let format_kind = if url.contains(".m3u8") {
|
||||
"m3u8"
|
||||
} else {
|
||||
"video/mp4"
|
||||
};
|
||||
let mut format = VideoFormat::new(url.to_string(), "auto".to_string(), format_kind.to_string());
|
||||
if let Some(headers) = entry.get("http_headers").and_then(|value| value.as_object()) {
|
||||
for (key, value) in headers {
|
||||
if let Some(value) = value.as_str() {
|
||||
format.add_http_header(key.to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if entry
|
||||
.get("http_headers")
|
||||
.and_then(|value| value.as_object())
|
||||
.is_none()
|
||||
{
|
||||
format.add_http_header("Referer".to_string(), format!("{}/", self.url));
|
||||
}
|
||||
|
||||
let mut item = VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
url.to_string(),
|
||||
"spankbang".to_string(),
|
||||
thumb,
|
||||
duration,
|
||||
)
|
||||
.formats(vec![format]);
|
||||
|
||||
if let Some(views) = entry
|
||||
.get("view_count")
|
||||
.and_then(|value| value.as_u64())
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
{
|
||||
item = item.views(views);
|
||||
}
|
||||
if let Some(uploader) = entry
|
||||
.get("uploader")
|
||||
.and_then(|value| value.as_str())
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
item = item.uploader(uploader.to_string());
|
||||
}
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
async fn fallback_items_with_working_media(
|
||||
&self,
|
||||
page_url: &str,
|
||||
options: &ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let fallback_items = self.fallback_items_from_ytdlp(page_url, 72);
|
||||
if fallback_items.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let mut requester = requester_or_default(
|
||||
options,
|
||||
"spankbang",
|
||||
"spankbang.fallback_items_with_working_media.missing_requester",
|
||||
);
|
||||
let mut working_items = Vec::new();
|
||||
|
||||
for item in fallback_items {
|
||||
let format_headers = item
|
||||
.formats
|
||||
.as_ref()
|
||||
.and_then(|formats| formats.first())
|
||||
.map(|format| format.http_headers_pairs())
|
||||
.unwrap_or_default();
|
||||
let media_url = item
|
||||
.formats
|
||||
.as_ref()
|
||||
.and_then(|formats| formats.first())
|
||||
.map(|format| format.url.clone())
|
||||
.unwrap_or_else(|| item.url.clone());
|
||||
if media_url.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut headers = format_headers;
|
||||
if !headers
|
||||
.iter()
|
||||
.any(|(key, _)| key.eq_ignore_ascii_case("range"))
|
||||
{
|
||||
headers.push(("Range".to_string(), "bytes=0-2047".to_string()));
|
||||
}
|
||||
|
||||
let is_working = match requester
|
||||
.get_raw_with_headers_timeout(&media_url, headers, Some(Duration::from_secs(20)))
|
||||
.await
|
||||
{
|
||||
Ok(response) => response.status().is_success(),
|
||||
Err(_) => false,
|
||||
};
|
||||
|
||||
if is_working {
|
||||
working_items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
working_items
|
||||
}
|
||||
|
||||
fn build_query_url(&self, query: &str, page: u32, sort: &str) -> String {
|
||||
let encoded_query = Self::encode_search_query(query);
|
||||
let mut url = if page > 1 {
|
||||
format!("{}/s/{encoded_query}/{page}/", self.url)
|
||||
} else {
|
||||
format!("{}/s/{encoded_query}/", self.url)
|
||||
};
|
||||
|
||||
match Self::normalize_query_sort(sort) {
|
||||
"new" => url.push_str("?o=new"),
|
||||
"popular" => url.push_str("?o=popular"),
|
||||
"featured" => url.push_str("?o=featured"),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
url
|
||||
}
|
||||
|
||||
fn normalize_url(&self, url: &str) -> String {
|
||||
if url.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
if url.starts_with("http://") || url.starts_with("https://") {
|
||||
return url.to_string();
|
||||
}
|
||||
if url.starts_with("//") {
|
||||
return format!("https:{url}");
|
||||
}
|
||||
if url.starts_with('/') {
|
||||
return format!("{}{}", self.url, url);
|
||||
}
|
||||
format!("{}/{}", self.url, url.trim_start_matches("./"))
|
||||
}
|
||||
|
||||
fn proxy_url(&self, proxy_base_url: &str, url: &str) -> String {
|
||||
let path = url
|
||||
.strip_prefix(&self.url)
|
||||
.unwrap_or(url)
|
||||
.trim_start_matches('/');
|
||||
if proxy_base_url.is_empty() {
|
||||
return format!("/proxy/spankbang/{path}");
|
||||
}
|
||||
format!(
|
||||
"{}/proxy/spankbang/{path}",
|
||||
proxy_base_url.trim_end_matches('/')
|
||||
)
|
||||
}
|
||||
|
||||
fn decode_html(text: &str) -> String {
|
||||
decode(text.as_bytes())
|
||||
.to_string()
|
||||
.unwrap_or_else(|_| text.to_string())
|
||||
}
|
||||
|
||||
fn collapse_whitespace(text: &str) -> String {
|
||||
text.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
fn text_of(element: &ElementRef<'_>) -> String {
|
||||
Self::collapse_whitespace(&element.text().collect::<Vec<_>>().join(" "))
|
||||
}
|
||||
|
||||
fn parse_duration(text: &str) -> u32 {
|
||||
let raw = Self::collapse_whitespace(text);
|
||||
if raw.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if raw.contains(':') {
|
||||
return parse_time_to_seconds(&raw)
|
||||
.and_then(|seconds| u32::try_from(seconds).ok())
|
||||
.unwrap_or(0);
|
||||
}
|
||||
|
||||
let mut total = 0;
|
||||
let mut digits = String::new();
|
||||
|
||||
for ch in raw.chars() {
|
||||
if ch.is_ascii_digit() {
|
||||
digits.push(ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
if digits.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let value = digits.parse::<u32>().unwrap_or(0);
|
||||
match ch.to_ascii_lowercase() {
|
||||
'h' => total += value * 3600,
|
||||
'm' => total += value * 60,
|
||||
's' => total += value,
|
||||
_ => {}
|
||||
}
|
||||
digits.clear();
|
||||
}
|
||||
|
||||
if total == 0 && !digits.is_empty() {
|
||||
digits.parse::<u32>().unwrap_or(0)
|
||||
} else {
|
||||
total
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_rating(text: &str) -> Option<f32> {
|
||||
let cleaned = Self::collapse_whitespace(text)
|
||||
.trim_end_matches('%')
|
||||
.trim()
|
||||
.to_string();
|
||||
if cleaned.is_empty() || cleaned == "-" {
|
||||
return None;
|
||||
}
|
||||
cleaned.parse::<f32>().ok()
|
||||
}
|
||||
|
||||
fn parse_card(
|
||||
&self,
|
||||
card: ElementRef<'_>,
|
||||
video_link_selector: &Selector,
|
||||
title_selector: &Selector,
|
||||
thumb_selector: &Selector,
|
||||
preview_selector: &Selector,
|
||||
length_selector: &Selector,
|
||||
views_selector: &Selector,
|
||||
rating_selector: &Selector,
|
||||
meta_link_selector: &Selector,
|
||||
proxy_base_url: &str,
|
||||
) -> Option<VideoItem> {
|
||||
let card_html = card.html();
|
||||
let card_text = Self::collapse_whitespace(&card.text().collect::<Vec<_>>().join(" "));
|
||||
if card_html.contains("SpankBang Gold") || card_text.contains("SpankBang Gold") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let id = card.value().attr("data-id")?.to_string();
|
||||
let href = card
|
||||
.select(video_link_selector)
|
||||
.find_map(|link| link.value().attr("href"))
|
||||
.map(ToString::to_string)?;
|
||||
let detail_url = self.normalize_url(&href);
|
||||
let thumb = card
|
||||
.select(thumb_selector)
|
||||
.find_map(|img| img.value().attr("src"))
|
||||
.map(|src| self.normalize_url(src))
|
||||
.unwrap_or_default();
|
||||
let preview = card
|
||||
.select(preview_selector)
|
||||
.find_map(|source| source.value().attr("data-src"))
|
||||
.map(|src| self.normalize_url(src));
|
||||
let duration = card
|
||||
.select(length_selector)
|
||||
.next()
|
||||
.map(|element| Self::parse_duration(&Self::text_of(&element)))
|
||||
.unwrap_or(0);
|
||||
let views = card
|
||||
.select(views_selector)
|
||||
.next()
|
||||
.and_then(|element| parse_abbreviated_number(&Self::text_of(&element)));
|
||||
let rating = card
|
||||
.select(rating_selector)
|
||||
.next()
|
||||
.and_then(|element| Self::parse_rating(&Self::text_of(&element)));
|
||||
let title = card
|
||||
.select(title_selector)
|
||||
.next()
|
||||
.and_then(|link| link.value().attr("title"))
|
||||
.map(Self::decode_html)
|
||||
.unwrap_or_else(|| {
|
||||
card.select(thumb_selector)
|
||||
.next()
|
||||
.and_then(|img| img.value().attr("alt"))
|
||||
.map(Self::decode_html)
|
||||
.unwrap_or_default()
|
||||
});
|
||||
|
||||
if title.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut item = VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
self.proxy_url(proxy_base_url, &href),
|
||||
"spankbang".to_string(),
|
||||
thumb,
|
||||
duration,
|
||||
);
|
||||
|
||||
if let Some(views) = views {
|
||||
item = item.views(views);
|
||||
}
|
||||
if let Some(rating) = rating {
|
||||
item = item.rating(rating);
|
||||
}
|
||||
if let Some(preview) = preview {
|
||||
let mut format =
|
||||
VideoFormat::new(preview.clone(), "preview".to_string(), "video/mp4".to_string());
|
||||
format.add_http_header("Referer".to_string(), detail_url.clone());
|
||||
item = item.preview(preview).formats(vec![format]);
|
||||
}
|
||||
|
||||
if let Some(meta_link) = card.select(meta_link_selector).next() {
|
||||
let uploader = Self::decode_html(&Self::text_of(&meta_link));
|
||||
if !uploader.is_empty() {
|
||||
item = item.uploader(uploader);
|
||||
}
|
||||
if let Some(meta_href) = meta_link.value().attr("href") {
|
||||
let uploader_url = self.normalize_url(meta_href);
|
||||
if !uploader_url.is_empty() {
|
||||
item = item.uploader_url(uploader_url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(item)
|
||||
}
|
||||
|
||||
fn get_video_items_from_html(&self, html: String, proxy_base_url: &str) -> Vec<VideoItem> {
|
||||
let document = Html::parse_document(&html);
|
||||
let video_list_selector = Selector::parse(r#"[data-testid="video-list"]"#).unwrap();
|
||||
let card_selector = Selector::parse(r#"[data-testid="video-item"]"#).unwrap();
|
||||
let video_link_selector = Selector::parse(r#"a[href*="/video/"]"#).unwrap();
|
||||
let title_selector = Selector::parse(r#"a[title]"#).unwrap();
|
||||
let thumb_selector = Selector::parse("picture img, img").unwrap();
|
||||
let preview_selector = Selector::parse(r#"source[data-src]"#).unwrap();
|
||||
let length_selector = Selector::parse(r#"[data-testid="video-item-length"]"#).unwrap();
|
||||
let views_selector = Selector::parse(r#"[data-testid="views"]"#).unwrap();
|
||||
let rating_selector = Selector::parse(r#"[data-testid="rates"]"#).unwrap();
|
||||
let meta_link_selector =
|
||||
Selector::parse(r#"[data-testid="video-info-with-badge"] a[data-testid="title"]"#)
|
||||
.unwrap();
|
||||
|
||||
let mut items = Vec::new();
|
||||
let roots = document.select(&video_list_selector).collect::<Vec<_>>();
|
||||
let cards = if let Some(root) = roots.last() {
|
||||
root.select(&card_selector).collect::<Vec<_>>()
|
||||
} else {
|
||||
document.select(&card_selector).collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
for card in cards {
|
||||
if let Some(item) = self.parse_card(
|
||||
card,
|
||||
&video_link_selector,
|
||||
&title_selector,
|
||||
&thumb_selector,
|
||||
&preview_selector,
|
||||
&length_selector,
|
||||
&views_selector,
|
||||
&rating_selector,
|
||||
&meta_link_selector,
|
||||
proxy_base_url,
|
||||
) {
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u32,
|
||||
sort: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let video_url = self.build_get_url(page, sort);
|
||||
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());
|
||||
}
|
||||
items.clone()
|
||||
}
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
let mut requester =
|
||||
requester_or_default(&options, "spankbang", "spankbang.get.missing_requester");
|
||||
let text = match requester
|
||||
.get_with_headers(&video_url, self.request_headers(), None)
|
||||
.await
|
||||
{
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
report_provider_error(
|
||||
"spankbang",
|
||||
"get.request",
|
||||
&format!("url={video_url}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
let fallback_items = self
|
||||
.fallback_items_with_working_media(&video_url, &options)
|
||||
.await;
|
||||
if !fallback_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), fallback_items.clone());
|
||||
return Ok(fallback_items);
|
||||
}
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
|
||||
if text.trim().is_empty() {
|
||||
report_provider_error(
|
||||
"spankbang",
|
||||
"get.empty_response",
|
||||
&format!("url={video_url}"),
|
||||
)
|
||||
.await;
|
||||
let fallback_items = self
|
||||
.fallback_items_with_working_media(&video_url, &options)
|
||||
.await;
|
||||
if !fallback_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), fallback_items.clone());
|
||||
return Ok(fallback_items);
|
||||
}
|
||||
return Ok(old_items);
|
||||
}
|
||||
|
||||
if Self::is_cloudflare_block(&text) {
|
||||
report_provider_error(
|
||||
"spankbang",
|
||||
"get.cloudflare_block",
|
||||
&format!("url={video_url}"),
|
||||
)
|
||||
.await;
|
||||
let fallback_items = self
|
||||
.fallback_items_with_working_media(&video_url, &options)
|
||||
.await;
|
||||
if !fallback_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), fallback_items.clone());
|
||||
return Ok(fallback_items);
|
||||
}
|
||||
return Ok(old_items);
|
||||
}
|
||||
|
||||
let proxy_base_url = options.public_url_base.as_deref().unwrap_or_default();
|
||||
let video_items = self.get_video_items_from_html(text, proxy_base_url);
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
return Ok(video_items);
|
||||
}
|
||||
|
||||
Ok(old_items)
|
||||
}
|
||||
|
||||
async fn query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u32,
|
||||
query: &str,
|
||||
sort: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let video_url = self.build_query_url(query, page, sort);
|
||||
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());
|
||||
}
|
||||
items.clone()
|
||||
}
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
let mut requester =
|
||||
requester_or_default(&options, "spankbang", "spankbang.query.missing_requester");
|
||||
let text = match requester
|
||||
.get_with_headers(&video_url, self.request_headers(), None)
|
||||
.await
|
||||
{
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
report_provider_error(
|
||||
"spankbang",
|
||||
"query.request",
|
||||
&format!("url={video_url}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
let fallback_items = self
|
||||
.fallback_items_with_working_media(&video_url, &options)
|
||||
.await;
|
||||
if !fallback_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), fallback_items.clone());
|
||||
return Ok(fallback_items);
|
||||
}
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
|
||||
if text.trim().is_empty() {
|
||||
report_provider_error(
|
||||
"spankbang",
|
||||
"query.empty_response",
|
||||
&format!("url={video_url}"),
|
||||
)
|
||||
.await;
|
||||
let fallback_items = self
|
||||
.fallback_items_with_working_media(&video_url, &options)
|
||||
.await;
|
||||
if !fallback_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), fallback_items.clone());
|
||||
return Ok(fallback_items);
|
||||
}
|
||||
return Ok(old_items);
|
||||
}
|
||||
|
||||
if Self::is_cloudflare_block(&text) {
|
||||
report_provider_error(
|
||||
"spankbang",
|
||||
"query.cloudflare_block",
|
||||
&format!("url={video_url}"),
|
||||
)
|
||||
.await;
|
||||
let fallback_items = self
|
||||
.fallback_items_with_working_media(&video_url, &options)
|
||||
.await;
|
||||
if !fallback_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), fallback_items.clone());
|
||||
return Ok(fallback_items);
|
||||
}
|
||||
return Ok(old_items);
|
||||
}
|
||||
|
||||
let proxy_base_url = options.public_url_base.as_deref().unwrap_or_default();
|
||||
let video_items = self.get_video_items_from_html(text, proxy_base_url);
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
return Ok(video_items);
|
||||
}
|
||||
|
||||
Ok(old_items)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for SpankbangProvider {
|
||||
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::<u32>().unwrap_or(1);
|
||||
|
||||
let videos = match query {
|
||||
Some(query) if !query.trim().is_empty() => {
|
||||
self.query(cache, page, &query, &sort, options).await
|
||||
}
|
||||
_ => self.get(cache, page, &sort, options).await,
|
||||
};
|
||||
|
||||
match videos {
|
||||
Ok(videos) => videos,
|
||||
Err(e) => {
|
||||
report_provider_error(
|
||||
"spankbang",
|
||||
"get_videos",
|
||||
&format!("page={page}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
||||
Some(self.build_channel(clientversion))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::SpankbangProvider;
|
||||
|
||||
#[test]
|
||||
fn builds_top_level_urls() {
|
||||
let provider = SpankbangProvider::new();
|
||||
assert_eq!(
|
||||
provider.build_get_url(1, "trending"),
|
||||
"https://spankbang.com/trending_videos/"
|
||||
);
|
||||
assert_eq!(
|
||||
provider.build_get_url(2, "upcoming"),
|
||||
"https://spankbang.com/upcoming/2/"
|
||||
);
|
||||
assert_eq!(
|
||||
provider.build_get_url(2, "new"),
|
||||
"https://spankbang.com/new_videos/2/"
|
||||
);
|
||||
assert_eq!(
|
||||
provider.build_get_url(2, "popular"),
|
||||
"https://spankbang.com/most_popular/2/?p=w"
|
||||
);
|
||||
assert_eq!(
|
||||
provider.build_get_url(1, "featured"),
|
||||
"https://spankbang.com/trending_videos/"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_search_urls_with_exact_sort_shape() {
|
||||
let provider = SpankbangProvider::new();
|
||||
assert_eq!(
|
||||
provider.build_query_url("adriana chechik", 1, "trending"),
|
||||
"https://spankbang.com/s/adriana+chechik/"
|
||||
);
|
||||
assert_eq!(
|
||||
provider.build_query_url("adriana chechik", 2, "new"),
|
||||
"https://spankbang.com/s/adriana+chechik/2/?o=new"
|
||||
);
|
||||
assert_eq!(
|
||||
provider.build_query_url("adriana chechik", 2, "popular"),
|
||||
"https://spankbang.com/s/adriana+chechik/2/?o=popular"
|
||||
);
|
||||
assert_eq!(
|
||||
provider.build_query_url("adriana chechik", 2, "featured"),
|
||||
"https://spankbang.com/s/adriana+chechik/2/?o=featured"
|
||||
);
|
||||
assert_eq!(
|
||||
provider.build_query_url("無修正", 1, "trending"),
|
||||
"https://spankbang.com/s/%E7%84%A1%E4%BF%AE%E6%AD%A3/"
|
||||
);
|
||||
assert_eq!(
|
||||
provider.request_headers(),
|
||||
vec![("Referer".to_string(), "https://spankbang.com/".to_string())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_cards_and_rewrites_to_proxy_url() {
|
||||
let provider = SpankbangProvider::new();
|
||||
let html = r#"
|
||||
<div data-testid="video-item" data-id="6597754" class="js-video-item z-0 flex flex-col">
|
||||
<a href="/3xeuy/video/adriana+s+fleshlight+insertion" class="relative mb-1 overflow-hidden rounded bg-neutral-900">
|
||||
<picture>
|
||||
<img
|
||||
src="https://tbi.sb-cd.com/t/6597754/6/5/w:300/t6-enh/adriana-s-fleshlight-insertion.jpg"
|
||||
alt="Adriana's Fleshlight Insertion"
|
||||
/>
|
||||
</picture>
|
||||
<video>
|
||||
<source data-src="https://tbv.sb-cd.com/t/6597754/6/5/td.mp4" type="video/mp4" />
|
||||
</video>
|
||||
<div data-testid="video-item-length">17m</div>
|
||||
</a>
|
||||
<div data-testid="video-info-with-badge">
|
||||
<div class="flex justify-between">
|
||||
<a data-testid="title" href="/76/pornstar/adriana+chechik/">
|
||||
<span>Adriana Chechik</span>
|
||||
</a>
|
||||
<span data-testid="views"><span></span><span>35K</span></span>
|
||||
<span data-testid="rates"><span></span><span>96%</span></span>
|
||||
</div>
|
||||
<p>
|
||||
<a href="/3xeuy/video/adriana+s+fleshlight+insertion" title="Adriana's Fleshlight Insertion">
|
||||
<span>Adriana's Fleshlight Insertion</span>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
"#;
|
||||
|
||||
let items = provider.get_video_items_from_html(html.to_string(), "https://example.com");
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].id, "6597754");
|
||||
assert_eq!(items[0].title, "Adriana's Fleshlight Insertion");
|
||||
assert_eq!(
|
||||
items[0].url,
|
||||
"https://example.com/proxy/spankbang/3xeuy/video/adriana+s+fleshlight+insertion"
|
||||
);
|
||||
assert_eq!(
|
||||
items[0].thumb,
|
||||
"https://tbi.sb-cd.com/t/6597754/6/5/w:300/t6-enh/adriana-s-fleshlight-insertion.jpg"
|
||||
);
|
||||
assert_eq!(
|
||||
items[0].preview,
|
||||
Some("https://tbv.sb-cd.com/t/6597754/6/5/td.mp4".to_string())
|
||||
);
|
||||
assert_eq!(items[0].duration, 1020);
|
||||
assert_eq!(items[0].views, Some(35_000));
|
||||
assert_eq!(items[0].rating, Some(96.0));
|
||||
assert_eq!(items[0].uploader, Some("Adriana Chechik".to_string()));
|
||||
assert_eq!(
|
||||
items[0].uploaderUrl,
|
||||
Some("https://spankbang.com/76/pornstar/adriana+chechik/".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_spankbang_gold_cards() {
|
||||
let provider = SpankbangProvider::new();
|
||||
let html = r#"
|
||||
<div data-testid="video-item" data-id="1">
|
||||
<a href="/gold/video/locked">
|
||||
<picture>
|
||||
<img src="https://example.com/gold.jpg" alt="Gold video" />
|
||||
</picture>
|
||||
<div>SpankBang Gold</div>
|
||||
<div data-testid="video-item-length">10m</div>
|
||||
</a>
|
||||
<div data-testid="video-info-with-badge">
|
||||
<span data-testid="views"><span>1K</span></span>
|
||||
<p><a href="/gold/video/locked" title="Gold video"><span>Gold video</span></a></p>
|
||||
</div>
|
||||
</div>
|
||||
<div data-testid="video-item" data-id="2">
|
||||
<a href="/free/video/open">
|
||||
<picture>
|
||||
<img src="https://example.com/free.jpg" alt="Free video" />
|
||||
</picture>
|
||||
<div data-testid="video-item-length">5m</div>
|
||||
</a>
|
||||
<div data-testid="video-info-with-badge">
|
||||
<span data-testid="views"><span>2K</span></span>
|
||||
<p><a href="/free/video/open" title="Free video"><span>Free video</span></a></p>
|
||||
</div>
|
||||
</div>
|
||||
"#;
|
||||
|
||||
let items = provider.get_video_items_from_html(html.to_string(), "https://example.com");
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].id, "2");
|
||||
assert_eq!(items[0].title, "Free video");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_primary_video_list_over_header_dropdown_cards() {
|
||||
let provider = SpankbangProvider::new();
|
||||
let html = r#"
|
||||
<div data-testid="video-list">
|
||||
<div data-testid="video-item" data-id="111">
|
||||
<a href="/wrong/video/header-card">
|
||||
<picture><img src="https://example.com/wrong.jpg" alt="Wrong header card" /></picture>
|
||||
<div data-testid="video-item-length">5m</div>
|
||||
</a>
|
||||
<div data-testid="video-info-with-badge">
|
||||
<span data-testid="views"><span>1K</span></span>
|
||||
<p><a href="/wrong/video/header-card" title="Wrong header card"><span>Wrong header card</span></a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div data-testid="video-list">
|
||||
<div data-testid="video-item" data-id="222">
|
||||
<a href="/right/video/adriana+chechik">
|
||||
<picture><img src="https://example.com/right.jpg" alt="Right result" /></picture>
|
||||
<div data-testid="video-item-length">17m</div>
|
||||
</a>
|
||||
<div data-testid="video-info-with-badge">
|
||||
<span data-testid="views"><span>35K</span></span>
|
||||
<span data-testid="rates"><span>96%</span></span>
|
||||
<p><a href="/right/video/adriana+chechik" title="Right result"><span>Right result</span></a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"#;
|
||||
|
||||
let items = provider.get_video_items_from_html(html.to_string(), "https://example.com");
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].id, "222");
|
||||
assert_eq!(items[0].title, "Right result");
|
||||
}
|
||||
}
|
||||
498
src/providers/sxyprn.rs
Normal file
@@ -0,0 +1,498 @@
|
||||
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;
|
||||
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;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "mainstream-tube",
|
||||
tags: &["tube", "community", "mixed"],
|
||||
};
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
|
||||
Channel {
|
||||
id: "sxyprn".to_string(),
|
||||
name: "SexyPorn".to_string(),
|
||||
description: "Free Porn Site".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=sxyprn.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: "latest".to_string(),
|
||||
title: "Latest".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "views".to_string(),
|
||||
title: "Views".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "rating".to_string(),
|
||||
title: "Rating".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "orgasmic".to_string(),
|
||||
title: "Orgasmic".to_string(),
|
||||
},
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
ChannelOption {
|
||||
id: "filter".to_string(),
|
||||
title: "Filter".to_string(),
|
||||
description: "Filter the Videos".to_string(),
|
||||
systemImage: "line.horizontal.3.decrease.circle".to_string(),
|
||||
colorName: "green".to_string(),
|
||||
options: vec![
|
||||
FilterOption {
|
||||
id: "top".to_string(),
|
||||
title: "Top".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "other".to_string(),
|
||||
title: "Other".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "all".to_string(),
|
||||
title: "All".to_string(),
|
||||
},
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
nsfw: true,
|
||||
cacheDuration: Some(1800),
|
||||
}
|
||||
}
|
||||
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_or_else(|| "top".to_string());
|
||||
let filter_string = match filter.as_str() {
|
||||
"other" => "other",
|
||||
"all" => "all",
|
||||
_ => "top",
|
||||
};
|
||||
let mut requester =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
|
||||
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 = match requester.get(&url_str, None).await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
crate::providers::report_provider_error(
|
||||
"sxyprn",
|
||||
"get.request",
|
||||
&format!("url={url_str}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
// 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, &options)
|
||||
.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 =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
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 = match requester.get(&url_str, None).await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
crate::providers::report_provider_error(
|
||||
"sxyprn",
|
||||
"query.request",
|
||||
&format!("url={url_str}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
|
||||
let video_items = match self
|
||||
.get_video_items_from_html(text.clone(), pool, requester, &options)
|
||||
.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,
|
||||
options: &ServerOptions,
|
||||
) -> 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 =
|
||||
crate::providers::build_proxy_url(options, "sxyprn", &format!("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(|| {
|
||||
crate::providers::build_proxy_url(options, "sxyprn", &format!("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![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
||||
Some(self.build_channel(clientversion))
|
||||
}
|
||||
}
|
||||
352
src/providers/tnaflix.rs
Normal file
@@ -0,0 +1,352 @@
|
||||
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::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;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "mainstream-tube",
|
||||
tags: &["mainstream", "legacy", "studio"],
|
||||
};
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TnaflixProvider {
|
||||
url: String,
|
||||
}
|
||||
|
||||
impl TnaflixProvider {
|
||||
pub fn new() -> Self {
|
||||
TnaflixProvider {
|
||||
url: "https://www.tnaflix.com".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
|
||||
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,
|
||||
},
|
||||
],
|
||||
nsfw: true,
|
||||
cacheDuration: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
sort: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let sort_string = match sort {
|
||||
"featured" => "featured",
|
||||
"toprated" => "toprated",
|
||||
_ => "new",
|
||||
};
|
||||
let duration_string = options
|
||||
.duration
|
||||
.clone()
|
||||
.unwrap_or_else(|| "all".to_string());
|
||||
|
||||
let video_url = format!(
|
||||
"{}/{}/{}?d={}",
|
||||
self.url, sort_string, page, duration_string
|
||||
);
|
||||
|
||||
// Cache Logic
|
||||
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("Requester missing")?;
|
||||
let text = requester
|
||||
.get(&video_url, None)
|
||||
.await
|
||||
.map_err(|e| format!("{}", e))?;
|
||||
|
||||
let video_items = self.get_video_items_from_html(text);
|
||||
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.to_lowercase().trim().replace(" ", "+");
|
||||
let duration_string = options
|
||||
.duration
|
||||
.clone()
|
||||
.unwrap_or_else(|| "all".to_string());
|
||||
|
||||
let video_url = format!(
|
||||
"{}/search?what={}&d={}&page={}",
|
||||
self.url, search_string, duration_string, 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("Requester missing")?;
|
||||
let text = requester
|
||||
.get(&video_url, None)
|
||||
.await
|
||||
.map_err(|e| format!("{}", e))?;
|
||||
|
||||
let video_items = self.get_video_items_from_html(text);
|
||||
if !video_items.is_empty() {
|
||||
cache.insert(video_url, video_items.clone());
|
||||
}
|
||||
Ok(video_items)
|
||||
}
|
||||
|
||||
fn get_video_items_from_html(&self, html: String) -> Vec<VideoItem> {
|
||||
if html.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let mut items = Vec::new();
|
||||
|
||||
// Safe helper for splitting
|
||||
let get_part = |input: &str, sep: &str, idx: usize| -> Option<String> {
|
||||
input.split(sep).nth(idx).map(|s| s.to_string())
|
||||
};
|
||||
|
||||
// Navigate to the video list container safely
|
||||
let list_part = match html.split("row video-list").nth(1) {
|
||||
Some(p) => match p.split("pagination ").next() {
|
||||
Some(inner) => inner,
|
||||
None => return vec![],
|
||||
},
|
||||
None => return vec![],
|
||||
};
|
||||
|
||||
let raw_videos: Vec<&str> = list_part
|
||||
.split("col-xs-6 col-md-4 col-xl-3 mb-3")
|
||||
.skip(1)
|
||||
.collect();
|
||||
|
||||
for (idx, segment) in raw_videos.iter().enumerate() {
|
||||
let item: Option<VideoItem> = (|| {
|
||||
let video_url = get_part(segment, " href=\"", 1)?
|
||||
.split("\"")
|
||||
.next()?
|
||||
.to_string();
|
||||
|
||||
let mut title = get_part(segment, "class=\"video-title text-break\">", 1)?
|
||||
.split("<")
|
||||
.next()?
|
||||
.trim()
|
||||
.to_string();
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
|
||||
let id = video_url.split("/").nth(5)?.to_string();
|
||||
|
||||
let thumb = if segment.contains("data-src=\"") {
|
||||
get_part(segment, "data-src=\"", 1)?
|
||||
.split("\"")
|
||||
.next()?
|
||||
.to_string()
|
||||
} else {
|
||||
get_part(segment, "<img src=\"", 1)?
|
||||
.split("\"")
|
||||
.next()?
|
||||
.to_string()
|
||||
};
|
||||
|
||||
let raw_duration = get_part(segment, "thumb-icon video-duration\">", 1)?
|
||||
.split("<")
|
||||
.next()?
|
||||
.to_string();
|
||||
let duration = parse_time_to_seconds(&raw_duration).unwrap_or(0) as u32;
|
||||
|
||||
let views = if segment.contains("icon-eye\"></i>") {
|
||||
let v_str = get_part(segment, "icon-eye\"></i>", 1)?
|
||||
.split("<")
|
||||
.next()?
|
||||
.trim()
|
||||
.to_string();
|
||||
parse_abbreviated_number(&v_str).unwrap_or(0) as u32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let preview = get_part(segment, "data-trailer=\"", 1)?
|
||||
.split("\"")
|
||||
.next()?
|
||||
.to_string();
|
||||
|
||||
Some(
|
||||
VideoItem::new(id, title, video_url, "tnaflix".to_string(), thumb, duration)
|
||||
.views(views)
|
||||
.preview(preview),
|
||||
)
|
||||
})();
|
||||
|
||||
if let Some(v) = item {
|
||||
items.push(v);
|
||||
} else {
|
||||
eprintln!("Tnaflix: Failed to parse item index {}", idx);
|
||||
tokio::spawn(async move {
|
||||
let _ = send_discord_error_report(
|
||||
format!("Tnaflix Parse Error at index {}", idx),
|
||||
None,
|
||||
Some("Tnaflix Provider"),
|
||||
None,
|
||||
file!(),
|
||||
line!(),
|
||||
module_path!(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
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 page_num = page.parse::<u8>().unwrap_or(1);
|
||||
|
||||
let result = match query {
|
||||
Some(q) => self.query(cache, page_num, &q, options).await,
|
||||
None => self.get(cache, page_num, &sort, options).await,
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("Tnaflix Error: {}", e);
|
||||
|
||||
// 1. Create a collection of owned data so we don't hold references to `e`
|
||||
let mut error_reports = Vec::new();
|
||||
|
||||
// Iterating through the error chain to collect data into owned Strings
|
||||
for cause in e.iter().skip(1) {
|
||||
error_reports.push((
|
||||
cause.to_string(), // Title
|
||||
format_error_chain(cause), // Description/Chain
|
||||
format!("caused by: {}", cause), // Message
|
||||
));
|
||||
}
|
||||
|
||||
// 2. Now that we aren't holding any `&dyn StdError`, we can safely .await
|
||||
for (title, chain_str, msg) in error_reports {
|
||||
let _ = send_discord_error_report(
|
||||
title,
|
||||
Some(chain_str),
|
||||
Some("Pornzog Provider"),
|
||||
Some(&msg),
|
||||
file!(),
|
||||
line!(),
|
||||
module_path!(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// In a real app, you'd extract owned strings here
|
||||
// and await your discord reporter as we did for Pornzog
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
||||
Some(self.build_channel(clientversion))
|
||||
}
|
||||
}
|
||||
530
src/providers/tokyomotion.rs
Normal file
@@ -0,0 +1,530 @@
|
||||
use crate::DbPool;
|
||||
use crate::api::ClientVersion;
|
||||
use crate::providers::{Provider, report_provider_error, requester_or_default};
|
||||
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 regex::Regex;
|
||||
use url::form_urlencoded::Serializer;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "jav",
|
||||
tags: &["japanese", "amateur", "jav"],
|
||||
};
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TokyomotionProvider {
|
||||
url: String,
|
||||
}
|
||||
|
||||
impl TokyomotionProvider {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
url: "https://www.tokyomotion.net".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
|
||||
Channel {
|
||||
id: "tokyomotion".to_string(),
|
||||
name: "Tokyo Motion".to_string(),
|
||||
description: "Japanese porn videos.".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=www.tokyomotion.net"
|
||||
.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: "being-watched".to_string(),
|
||||
title: "Being Watched".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "most-recent".to_string(),
|
||||
title: "Most Recent".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "most-viewed".to_string(),
|
||||
title: "Most Viewed".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "most-commented".to_string(),
|
||||
title: "Most Commented".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "top-rated".to_string(),
|
||||
title: "Top Rated".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "top-favorites".to_string(),
|
||||
title: "Top Favorites".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "longest".to_string(),
|
||||
title: "Longest".to_string(),
|
||||
},
|
||||
],
|
||||
multiSelect: false,
|
||||
}],
|
||||
nsfw: true,
|
||||
cacheDuration: Some(1800),
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_code_for_get(sort: &str) -> &'static str {
|
||||
match sort {
|
||||
"being-watched" => "bw",
|
||||
"most-recent" => "mr",
|
||||
"most-commented" => "md",
|
||||
"top-rated" => "tr",
|
||||
"top-favorites" => "tf",
|
||||
"longest" => "lg",
|
||||
_ => "mv",
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_code_for_query(sort: &str) -> &'static str {
|
||||
match sort {
|
||||
"being-watched" => "bw",
|
||||
"most-viewed" => "mv",
|
||||
"most-commented" => "md",
|
||||
"top-rated" => "tr",
|
||||
"top-favorites" => "tf",
|
||||
"longest" => "lg",
|
||||
_ => "mr",
|
||||
}
|
||||
}
|
||||
|
||||
fn build_get_url(&self, page: u32, sort: &str) -> String {
|
||||
format!(
|
||||
"{}/videos?t=a&o={}&page={page}",
|
||||
self.url,
|
||||
Self::sort_code_for_get(sort)
|
||||
)
|
||||
}
|
||||
|
||||
fn build_query_url(&self, query: &str, page: u32, sort: &str) -> String {
|
||||
let mut serializer = Serializer::new(String::new());
|
||||
serializer.append_pair("search_query", query);
|
||||
serializer.append_pair("search_type", "videos");
|
||||
serializer.append_pair("o", Self::sort_code_for_query(sort));
|
||||
serializer.append_pair("page", &page.to_string());
|
||||
|
||||
format!("{}/search?{}", self.url, serializer.finish())
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u32,
|
||||
sort: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let video_url = self.build_get_url(page, sort);
|
||||
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());
|
||||
}
|
||||
items.clone()
|
||||
}
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
let mut requester =
|
||||
requester_or_default(&options, "tokyomotion", "tokyomotion.get.missing_requester");
|
||||
let text = match requester.get(&video_url, None).await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
report_provider_error(
|
||||
"tokyomotion",
|
||||
"get.request",
|
||||
&format!("url={video_url}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
|
||||
if text.trim().is_empty() {
|
||||
report_provider_error(
|
||||
"tokyomotion",
|
||||
"get.empty_response",
|
||||
&format!("url={video_url}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
|
||||
let video_items = self.get_video_items_from_html(text);
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
return Ok(video_items);
|
||||
}
|
||||
|
||||
Ok(old_items)
|
||||
}
|
||||
|
||||
async fn query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u32,
|
||||
query: &str,
|
||||
sort: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let video_url = self.build_query_url(query, page, sort);
|
||||
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());
|
||||
}
|
||||
items.clone()
|
||||
}
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
let mut requester = requester_or_default(
|
||||
&options,
|
||||
"tokyomotion",
|
||||
"tokyomotion.query.missing_requester",
|
||||
);
|
||||
let text = match requester.get(&video_url, None).await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
report_provider_error(
|
||||
"tokyomotion",
|
||||
"query.request",
|
||||
&format!("url={video_url}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
|
||||
if text.trim().is_empty() {
|
||||
report_provider_error(
|
||||
"tokyomotion",
|
||||
"query.empty_response",
|
||||
&format!("url={video_url}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
|
||||
let video_items = self.get_video_items_from_html(text);
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
return Ok(video_items);
|
||||
}
|
||||
|
||||
Ok(old_items)
|
||||
}
|
||||
|
||||
fn extract_between<'a>(text: &'a str, start: &str, end: &str) -> Option<&'a str> {
|
||||
text.split(start).nth(1)?.split(end).next()
|
||||
}
|
||||
|
||||
fn normalize_url(&self, url: &str) -> String {
|
||||
if url.starts_with("http://") || url.starts_with("https://") {
|
||||
return url.to_string();
|
||||
}
|
||||
if url.starts_with("//") {
|
||||
return format!("https:{url}");
|
||||
}
|
||||
if url.starts_with('/') {
|
||||
return format!("{}{}", self.url, url);
|
||||
}
|
||||
format!("{}/{}", self.url, url.trim_start_matches("./"))
|
||||
}
|
||||
|
||||
fn parse_views(raw: &str) -> Option<u32> {
|
||||
let cleaned = raw
|
||||
.replace("views", "")
|
||||
.replace("view", "")
|
||||
.replace(',', "")
|
||||
.trim()
|
||||
.to_string();
|
||||
parse_abbreviated_number(&cleaned)
|
||||
}
|
||||
|
||||
fn parse_rating(raw: &str) -> Option<f32> {
|
||||
let cleaned = raw.replace('%', "").trim().to_string();
|
||||
if cleaned == "-" || cleaned.is_empty() {
|
||||
return None;
|
||||
}
|
||||
cleaned.parse::<f32>().ok()
|
||||
}
|
||||
|
||||
fn extract_id_from_url(url: &str) -> String {
|
||||
url.trim_end_matches('/')
|
||||
.split('/')
|
||||
.find_map(|part| {
|
||||
if part.chars().all(|c| c.is_ascii_digit()) {
|
||||
Some(part.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn get_video_items_from_html(&self, html: String) -> Vec<VideoItem> {
|
||||
if html.trim().is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let Ok(card_re) = Regex::new(
|
||||
r#"(?is)<a href="(?P<href>/video/(?P<id>\d+)/[^"]+)"\s+class="thumb-popu">(?P<body>.*?)</a>\s*<div class="video-added">.*?</div>\s*<div class="video-views pull-left">\s*(?P<views>.*?)\s*</div>\s*<div class="video-rating pull-right[^"]*">\s*.*?<b>(?P<rating>[^<]+)</b>"#,
|
||||
) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let mut items = Vec::new();
|
||||
|
||||
for captures in card_re.captures_iter(&html) {
|
||||
let href = captures
|
||||
.name("href")
|
||||
.map(|m| m.as_str())
|
||||
.unwrap_or_default();
|
||||
let video_url = self.normalize_url(href);
|
||||
let id = captures
|
||||
.name("id")
|
||||
.map(|m| m.as_str().to_string())
|
||||
.unwrap_or_else(|| Self::extract_id_from_url(&video_url));
|
||||
if id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let body = captures
|
||||
.name("body")
|
||||
.map(|m| m.as_str())
|
||||
.unwrap_or_default();
|
||||
let title_raw = Self::extract_between(
|
||||
body,
|
||||
"<span class=\"video-title title-truncate m-t-5\">",
|
||||
"<",
|
||||
)
|
||||
.or_else(|| Self::extract_between(body, "title=\"", "\""))
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
let title = decode(title_raw.as_bytes())
|
||||
.to_string()
|
||||
.unwrap_or(title_raw);
|
||||
if title.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let thumb = Self::extract_between(body, "<img src=\"", "\"")
|
||||
.map(|thumb| self.normalize_url(thumb))
|
||||
.unwrap_or_default();
|
||||
|
||||
let duration_raw = Self::extract_between(body, "<div class=\"duration\">", "<")
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
let duration = parse_time_to_seconds(&duration_raw).unwrap_or(0) as u32;
|
||||
|
||||
let views_raw = captures
|
||||
.name("views")
|
||||
.map(|m| m.as_str())
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
let views = Self::parse_views(&views_raw);
|
||||
|
||||
let rating_raw = captures
|
||||
.name("rating")
|
||||
.map(|m| m.as_str())
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
let rating = Self::parse_rating(&rating_raw);
|
||||
|
||||
let mut item = VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
video_url,
|
||||
"tokyomotion".to_string(),
|
||||
thumb,
|
||||
duration,
|
||||
);
|
||||
if let Some(views) = views {
|
||||
item = item.views(views);
|
||||
}
|
||||
if let Some(rating) = rating {
|
||||
item = item.rating(rating);
|
||||
}
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for TokyomotionProvider {
|
||||
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::<u32>().unwrap_or(1);
|
||||
|
||||
let videos = match query {
|
||||
Some(query) if !query.trim().is_empty() => {
|
||||
self.query(cache, page, &query, &sort, options).await
|
||||
}
|
||||
_ => self.get(cache, page, &sort, options).await,
|
||||
};
|
||||
|
||||
match videos {
|
||||
Ok(videos) => videos,
|
||||
Err(e) => {
|
||||
report_provider_error(
|
||||
"tokyomotion",
|
||||
"get_videos",
|
||||
&format!("page={page}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
||||
Some(self.build_channel(clientversion))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::TokyomotionProvider;
|
||||
|
||||
#[test]
|
||||
fn builds_get_url_with_requested_sort() {
|
||||
let provider = TokyomotionProvider::new();
|
||||
assert_eq!(
|
||||
provider.build_get_url(2, "most-viewed"),
|
||||
"https://www.tokyomotion.net/videos?t=a&o=mv&page=2"
|
||||
);
|
||||
assert_eq!(
|
||||
provider.build_get_url(2, "top-rated"),
|
||||
"https://www.tokyomotion.net/videos?t=a&o=tr&page=2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_query_url_with_requested_sort() {
|
||||
let provider = TokyomotionProvider::new();
|
||||
assert_eq!(
|
||||
provider.build_query_url("cute girl", 2, "most-recent"),
|
||||
"https://www.tokyomotion.net/search?search_query=cute+girl&search_type=videos&o=mr&page=2"
|
||||
);
|
||||
assert_eq!(
|
||||
provider.build_query_url("cute girl", 2, "top-favorites"),
|
||||
"https://www.tokyomotion.net/search?search_query=cute+girl&search_type=videos&o=tf&page=2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_tokyomotion_cards() {
|
||||
let provider = TokyomotionProvider::new();
|
||||
let html = r##"
|
||||
<div class="row">
|
||||
<div class="col-sm-4 col-md-3 col-lg-3">
|
||||
<div class="well well-sm">
|
||||
<a href="/video/6225200/いのりちゃん 着エロ iv-日本美女-cute-japanese-girl" class="thumb-popu">
|
||||
<div class="thumb-overlay">
|
||||
<img src="https://cdn.tokyo-motion.net/media/videos/tmb194/6225200/16.jpg" title="いのりちゃん 着エロ IV 日本美女 Cute Japanese Girl" alt="いのりちゃん 着エロ IV 日本美女 Cute Japanese Girl" class="img-responsive "/>
|
||||
<div class="hd-text-icon">HD</div>
|
||||
<div class="duration">
|
||||
01:55:27
|
||||
</div>
|
||||
</div>
|
||||
<span class="video-title title-truncate m-t-5">いのりちゃん 着エロ IV 日本美女 Cute Japanese Girl</span>
|
||||
</a>
|
||||
<div class="video-added">4 days ago</div>
|
||||
<div class="video-views pull-left">
|
||||
4000 views
|
||||
</div>
|
||||
<div class="video-rating pull-right ">
|
||||
<i class="fa fa-heart video-rating-heart "></i> <b>57%</b>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4 col-md-3 col-lg-3">
|
||||
<div class="well well-sm">
|
||||
<a href="/video/6222401/tattooed-trans-tease-jerking-on-cam" class="thumb-popu">
|
||||
<div class="thumb-overlay">
|
||||
<img src="https://cdn.tokyo-motion.net/media/videos/tmb194/6222401/1.jpg" title="Tattooed Trans Tease Jerking On Cam" alt="Tattooed Trans Tease Jerking On Cam" class="img-responsive "/>
|
||||
<div class="hd-text-icon">HD</div>
|
||||
<div class="duration">
|
||||
10:33
|
||||
</div>
|
||||
</div>
|
||||
<span class="video-title title-truncate m-t-5">Tattooed Trans Tease Jerking On Cam</span>
|
||||
</a>
|
||||
<div class="video-added">4 days ago</div>
|
||||
<div class="video-views pull-left">
|
||||
0 views
|
||||
</div>
|
||||
<div class="video-rating pull-right no-rating">
|
||||
<i class="fa fa-heart video-rating-heart no-rating"></i> <b>-</b>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"##;
|
||||
|
||||
let items = provider.get_video_items_from_html(html.to_string());
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0].id, "6225200");
|
||||
assert_eq!(
|
||||
items[0].url,
|
||||
"https://www.tokyomotion.net/video/6225200/いのりちゃん 着エロ iv-日本美女-cute-japanese-girl"
|
||||
);
|
||||
assert_eq!(
|
||||
items[0].thumb,
|
||||
"https://cdn.tokyo-motion.net/media/videos/tmb194/6225200/16.jpg"
|
||||
);
|
||||
assert_eq!(items[0].duration, 6927);
|
||||
assert_eq!(items[0].views, Some(4000));
|
||||
assert_eq!(items[0].rating, Some(57.0));
|
||||
assert_eq!(items[1].id, "6222401");
|
||||
assert_eq!(items[1].duration, 633);
|
||||
assert_eq!(items[1].views, Some(0));
|
||||
assert_eq!(items[1].rating, None);
|
||||
}
|
||||
}
|
||||
681
src/providers/viralxxxporn.rs
Normal file
@@ -0,0 +1,681 @@
|
||||
use crate::DbPool;
|
||||
use crate::api::ClientVersion;
|
||||
use crate::providers::{Provider, report_provider_error, requester_or_default};
|
||||
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, VideoFormat, VideoItem};
|
||||
use async_trait::async_trait;
|
||||
use error_chain::error_chain;
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
use regex::Regex;
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "mainstream-tube",
|
||||
tags: &["tube", "viral", "mixed"],
|
||||
};
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ViralxxxpornProvider {
|
||||
url: String,
|
||||
}
|
||||
|
||||
impl ViralxxxpornProvider {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
url: "https://viralxxxporn.com".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
|
||||
Channel {
|
||||
id: "viralxxxporn".to_string(),
|
||||
name: "Viralxxxporn".to_string(),
|
||||
description: "Latest viral porn videos.".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=viralxxxporn.com".to_string(),
|
||||
status: "active".to_string(),
|
||||
categories: vec![],
|
||||
options: vec![],
|
||||
nsfw: true,
|
||||
cacheDuration: Some(1800),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_latest_url(&self, page: u32) -> String {
|
||||
format!(
|
||||
"{}/latest-updates/?mode=async&function=get_block&block_id=list_videos_latest_videos_list&sort_by=post_date&from={page}",
|
||||
self.url
|
||||
)
|
||||
}
|
||||
|
||||
fn build_latest_headers(&self) -> Vec<(String, String)> {
|
||||
vec![(
|
||||
"Referer".to_string(),
|
||||
format!("{}/latest-updates/", self.url),
|
||||
)]
|
||||
}
|
||||
|
||||
fn build_search_path_query(query: &str, separator: &str) -> String {
|
||||
query.split_whitespace().collect::<Vec<_>>().join(separator)
|
||||
}
|
||||
|
||||
fn build_search_url(&self, query: &str, page: u32) -> String {
|
||||
let query_param = Self::build_search_path_query(query, "+");
|
||||
let path_query = Self::build_search_path_query(query, "-");
|
||||
format!(
|
||||
"{}/search/{path_query}/?mode=async&function=get_block&block_id=list_videos_videos_list_search_result&q={query_param}&from_videos={page}",
|
||||
self.url
|
||||
)
|
||||
}
|
||||
|
||||
fn build_search_headers(&self, query: &str) -> Vec<(String, String)> {
|
||||
let path_query = Self::build_search_path_query(query, "-");
|
||||
vec![(
|
||||
"Referer".to_string(),
|
||||
format!("{}/search/{path_query}/", self.url),
|
||||
)]
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u32,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let video_url = self.build_latest_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());
|
||||
}
|
||||
items.clone()
|
||||
}
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
let mut requester = requester_or_default(
|
||||
&options,
|
||||
"viralxxxporn",
|
||||
"viralxxxporn.get.missing_requester",
|
||||
);
|
||||
let text = match requester
|
||||
.get_with_headers(&video_url, self.build_latest_headers(), None)
|
||||
.await
|
||||
{
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
report_provider_error(
|
||||
"viralxxxporn",
|
||||
"get.request",
|
||||
&format!("url={video_url}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
|
||||
if text.trim().is_empty() {
|
||||
report_provider_error(
|
||||
"viralxxxporn",
|
||||
"get.empty_response",
|
||||
&format!("url={video_url}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
|
||||
let video_items = self.get_video_items_from_html(text);
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
return Ok(video_items);
|
||||
}
|
||||
|
||||
Ok(old_items)
|
||||
}
|
||||
|
||||
async fn query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u32,
|
||||
query: &str,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let video_url = self.build_search_url(query, 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());
|
||||
}
|
||||
items.clone()
|
||||
}
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
let mut requester = requester_or_default(
|
||||
&options,
|
||||
"viralxxxporn",
|
||||
"viralxxxporn.query.missing_requester",
|
||||
);
|
||||
let text = match requester
|
||||
.get_with_headers(&video_url, self.build_search_headers(query), None)
|
||||
.await
|
||||
{
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
report_provider_error(
|
||||
"viralxxxporn",
|
||||
"query.request",
|
||||
&format!("url={video_url}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
|
||||
if text.trim().is_empty() {
|
||||
report_provider_error(
|
||||
"viralxxxporn",
|
||||
"query.empty_response",
|
||||
&format!("url={video_url}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
|
||||
let video_items = self.get_video_items_from_html(text);
|
||||
if !video_items.is_empty() {
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url.clone(), video_items.clone());
|
||||
return Ok(video_items);
|
||||
}
|
||||
|
||||
Ok(old_items)
|
||||
}
|
||||
|
||||
fn extract_between<'a>(text: &'a str, start: &str, end: &str) -> Option<&'a str> {
|
||||
text.split(start).nth(1)?.split(end).next()
|
||||
}
|
||||
|
||||
fn normalize_ws(text: &str) -> String {
|
||||
text.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
fn decode_html(text: &str) -> String {
|
||||
decode(text.as_bytes())
|
||||
.to_string()
|
||||
.unwrap_or_else(|_| text.to_string())
|
||||
}
|
||||
|
||||
fn first_non_empty_attr(segment: &str, attrs: &[&str]) -> Option<String> {
|
||||
attrs.iter().find_map(|attr| {
|
||||
Self::extract_between(segment, attr, "\"")
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_thumb_url(&self, segment: &str) -> String {
|
||||
let thumb_raw = Self::first_non_empty_attr(
|
||||
segment,
|
||||
&[
|
||||
"data-original=\"",
|
||||
"data-webp=\"",
|
||||
"data-src=\"",
|
||||
"poster=\"",
|
||||
"src=\"",
|
||||
],
|
||||
)
|
||||
.unwrap_or_default();
|
||||
|
||||
if thumb_raw.starts_with("data:image/") {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
self.normalize_url(&thumb_raw)
|
||||
}
|
||||
|
||||
fn normalize_url(&self, url: &str) -> String {
|
||||
if url.starts_with("http://") || url.starts_with("https://") {
|
||||
return url.to_string();
|
||||
}
|
||||
if url.starts_with("//") {
|
||||
return format!("https:{url}");
|
||||
}
|
||||
if url.starts_with('/') {
|
||||
return format!("{}{}", self.url, url);
|
||||
}
|
||||
format!("{}/{}", self.url, url.trim_start_matches("./"))
|
||||
}
|
||||
|
||||
fn extract_id_from_url(url: &str) -> String {
|
||||
let parts = url
|
||||
.trim_end_matches('/')
|
||||
.split('/')
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
parts
|
||||
.windows(2)
|
||||
.find_map(|window| match window {
|
||||
["video", id] | ["videos", id] => Some((*id).to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.or_else(|| parts.last().map(|id| (*id).to_string()))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn strip_tags(text: &str) -> String {
|
||||
let Ok(tag_re) = Regex::new(r"(?is)<[^>]+>") else {
|
||||
return text.to_string();
|
||||
};
|
||||
tag_re.replace_all(text, " ").to_string()
|
||||
}
|
||||
|
||||
fn extract_duration_seconds(text: &str) -> Option<u32> {
|
||||
let colon_duration = Regex::new(r"\b(\d{1,2}:\d{2}(?::\d{2})?)\b")
|
||||
.ok()
|
||||
.and_then(|re| re.captures(text))
|
||||
.and_then(|caps| caps.get(1))
|
||||
.and_then(|m| parse_time_to_seconds(m.as_str()))
|
||||
.map(|seconds| seconds as u32);
|
||||
if colon_duration.is_some() {
|
||||
return colon_duration;
|
||||
}
|
||||
|
||||
let minute = Regex::new(r"(?i)\b(\d{1,3})\s*(?:min|mins|minute|minutes)\b")
|
||||
.ok()
|
||||
.and_then(|re| re.captures(text))
|
||||
.and_then(|caps| caps.get(1))
|
||||
.and_then(|m| m.as_str().parse::<u32>().ok());
|
||||
let second = Regex::new(r"(?i)\b(\d{1,3})\s*(?:sec|secs|second|seconds)\b")
|
||||
.ok()
|
||||
.and_then(|re| re.captures(text))
|
||||
.and_then(|caps| caps.get(1))
|
||||
.and_then(|m| m.as_str().parse::<u32>().ok());
|
||||
|
||||
match (minute, second) {
|
||||
(Some(min), Some(sec)) => Some(min * 60 + sec),
|
||||
(Some(min), None) => Some(min * 60),
|
||||
(None, Some(sec)) => Some(sec),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_views(text: &str) -> Option<u32> {
|
||||
let with_label = Regex::new(r"(?i)\b([0-9]+(?:\.[0-9]+)?\s*[kmb]?)\s*views?\b")
|
||||
.ok()
|
||||
.and_then(|re| re.captures(text))
|
||||
.and_then(|caps| caps.get(1))
|
||||
.and_then(|m| parse_abbreviated_number(m.as_str().trim()));
|
||||
if with_label.is_some() {
|
||||
return with_label;
|
||||
}
|
||||
|
||||
Regex::new(r"(?i)\b([0-9]+(?:\.[0-9]+)?\s*[kmb])\b")
|
||||
.ok()
|
||||
.and_then(|re| re.captures(text))
|
||||
.and_then(|caps| caps.get(1))
|
||||
.and_then(|m| parse_abbreviated_number(m.as_str().trim()))
|
||||
}
|
||||
|
||||
fn parse_anchor_items(&self, html: &str) -> Vec<VideoItem> {
|
||||
let Ok(link_re) = Regex::new(
|
||||
r#"(?is)<a[^>]+href="(?P<href>(?:https?://[^"]+)?/video/(?P<id>\d+)/[^"]+)"[^>]*>(?P<body>.*?)</a>"#,
|
||||
) else {
|
||||
return vec![];
|
||||
};
|
||||
let Ok(title_attr_re) = Regex::new(r#"(?is)\btitle="([^"]+)""#) else {
|
||||
return vec![];
|
||||
};
|
||||
let mut items = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
for captures in link_re.captures_iter(html) {
|
||||
let Some(id) = captures.name("id").map(|m| m.as_str().to_string()) else {
|
||||
continue;
|
||||
};
|
||||
if !seen.insert(id.clone()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let href = captures
|
||||
.name("href")
|
||||
.map(|m| self.normalize_url(m.as_str()))
|
||||
.unwrap_or_default();
|
||||
let body = captures
|
||||
.name("body")
|
||||
.map(|m| m.as_str())
|
||||
.unwrap_or_default();
|
||||
let Some(full_match) = captures.get(0) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let seg_start = full_match.start().saturating_sub(600);
|
||||
let seg_end = (full_match.end() + 1800).min(html.len());
|
||||
let segment = html.get(seg_start..seg_end).unwrap_or(body);
|
||||
|
||||
let title_from_attr = title_attr_re
|
||||
.captures(full_match.as_str())
|
||||
.and_then(|caps| caps.get(1))
|
||||
.map(|m| m.as_str().to_string())
|
||||
.unwrap_or_default();
|
||||
let title_from_body = Self::strip_tags(body);
|
||||
let title_source = if !title_from_attr.is_empty() {
|
||||
title_from_attr
|
||||
} else {
|
||||
title_from_body
|
||||
};
|
||||
let title = Self::normalize_ws(&Self::decode_html(&title_source));
|
||||
if title.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let thumb = self.extract_thumb_url(segment);
|
||||
let preview = Self::first_non_empty_attr(segment, &["data-preview=\""])
|
||||
.map(|value| self.normalize_url(&value))
|
||||
.unwrap_or_default();
|
||||
|
||||
let text_segment = Self::normalize_ws(&Self::decode_html(&Self::strip_tags(segment)));
|
||||
let duration = Self::extract_duration_seconds(segment)
|
||||
.or_else(|| Self::extract_duration_seconds(&text_segment))
|
||||
.unwrap_or(0);
|
||||
let views = Self::extract_views(segment)
|
||||
.or_else(|| Self::extract_views(&text_segment))
|
||||
.unwrap_or(0);
|
||||
|
||||
let mut item =
|
||||
VideoItem::new(id, title, href, "viralxxxporn".to_string(), thumb, duration);
|
||||
if views > 0 {
|
||||
item = item.views(views);
|
||||
}
|
||||
if !preview.is_empty() {
|
||||
let mut format = VideoFormat::new(
|
||||
preview.clone(),
|
||||
"preview".to_string(),
|
||||
"video/mp4".to_string(),
|
||||
);
|
||||
format.add_http_header("Referer".to_string(), item.url.clone());
|
||||
item = item.preview(preview).formats(vec![format]);
|
||||
}
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
fn get_video_items_from_html(&self, html: String) -> Vec<VideoItem> {
|
||||
if html.trim().is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let anchor_items = self.parse_anchor_items(&html);
|
||||
if !anchor_items.is_empty() {
|
||||
return anchor_items;
|
||||
}
|
||||
|
||||
let mut items = Vec::new();
|
||||
let content = html
|
||||
.split("<div class=\"pagination\"")
|
||||
.next()
|
||||
.unwrap_or(&html)
|
||||
.split("class=\"pagination\"")
|
||||
.next()
|
||||
.unwrap_or(&html);
|
||||
|
||||
let markers = [
|
||||
"<div class=\"thumb thumb_rel item \">",
|
||||
"<div class=\"item \">",
|
||||
"<div class=\"item thumb video_",
|
||||
"<article class=\"thumb",
|
||||
"<article class=\"item",
|
||||
];
|
||||
|
||||
for marker in markers {
|
||||
for segment in content.split(marker).skip(1) {
|
||||
let Some(video_url_raw) =
|
||||
Self::first_non_empty_attr(segment, &["<a href=\"", "href=\""])
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let video_url = self.normalize_url(&video_url_raw);
|
||||
let id = Self::extract_id_from_url(&video_url);
|
||||
if id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let title_raw = Self::first_non_empty_attr(segment, &["\" title=\"", "alt=\""])
|
||||
.or_else(|| {
|
||||
Self::extract_between(segment, "<strong class=\"title\">", "<")
|
||||
.map(ToString::to_string)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let title = decode(title_raw.as_bytes())
|
||||
.to_string()
|
||||
.unwrap_or(title_raw)
|
||||
.trim()
|
||||
.to_string();
|
||||
if title.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let thumb = self.extract_thumb_url(segment);
|
||||
let preview = Self::first_non_empty_attr(segment, &["data-preview=\""])
|
||||
.map(|value| self.normalize_url(&value))
|
||||
.unwrap_or_default();
|
||||
|
||||
let raw_duration = Self::extract_between(segment, "<div class=\"duration\">", "<")
|
||||
.or_else(|| Self::extract_between(segment, "<div class=\"time\">", "<"))
|
||||
.or_else(|| Self::extract_between(segment, "class=\"duration\">", "<"))
|
||||
.or_else(|| Self::extract_between(segment, "class=\"time\">", "<"))
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
let duration = parse_time_to_seconds(&raw_duration)
|
||||
.map(|v| v as u32)
|
||||
.or_else(|| Self::extract_duration_seconds(&raw_duration))
|
||||
.unwrap_or(0);
|
||||
|
||||
let views = Self::extract_between(segment, "<div class=\"views\">", "<")
|
||||
.or_else(|| Self::extract_between(segment, "class=\"views\">", "<"))
|
||||
.and_then(|value| parse_abbreviated_number(value.trim()))
|
||||
.or_else(|| Self::extract_views(segment))
|
||||
.unwrap_or(0);
|
||||
|
||||
let mut item = VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
video_url,
|
||||
"viralxxxporn".to_string(),
|
||||
thumb,
|
||||
duration,
|
||||
);
|
||||
if views > 0 {
|
||||
item = item.views(views);
|
||||
}
|
||||
if !preview.is_empty() {
|
||||
let mut format = VideoFormat::new(
|
||||
preview.clone(),
|
||||
"preview".to_string(),
|
||||
"video/mp4".to_string(),
|
||||
);
|
||||
format.add_http_header("Referer".to_string(), item.url.clone());
|
||||
item = item.preview(preview).formats(vec![format]);
|
||||
}
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
if !items.is_empty() {
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for ViralxxxpornProvider {
|
||||
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 page = page.parse::<u32>().unwrap_or(1);
|
||||
|
||||
let videos = match query {
|
||||
Some(q) if !q.trim().is_empty() => self.query(cache, page, &q, options).await,
|
||||
_ => self.get(cache, page, options).await,
|
||||
};
|
||||
|
||||
match videos {
|
||||
Ok(videos) => videos,
|
||||
Err(e) => {
|
||||
report_provider_error(
|
||||
"viralxxxporn",
|
||||
"get_videos",
|
||||
&format!("page={page}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
||||
Some(self.build_channel(clientversion))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ViralxxxpornProvider;
|
||||
|
||||
#[test]
|
||||
fn builds_latest_url_with_expected_endpoint() {
|
||||
let provider = ViralxxxpornProvider::new();
|
||||
assert_eq!(
|
||||
provider.build_latest_url(3),
|
||||
"https://viralxxxporn.com/latest-updates/?mode=async&function=get_block&block_id=list_videos_latest_videos_list&sort_by=post_date&from=3"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_search_url_and_referer_with_requested_encoding() {
|
||||
let provider = ViralxxxpornProvider::new();
|
||||
assert_eq!(
|
||||
provider.build_search_url("adriana chechik", 4),
|
||||
"https://viralxxxporn.com/search/adriana-chechik/?mode=async&function=get_block&block_id=list_videos_videos_list_search_result&q=adriana+chechik&from_videos=4"
|
||||
);
|
||||
assert_eq!(
|
||||
provider.build_search_headers("adriana chechik"),
|
||||
vec![(
|
||||
"Referer".to_string(),
|
||||
"https://viralxxxporn.com/search/adriana-chechik/".to_string()
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_common_kvs_item_markup() {
|
||||
let provider = ViralxxxpornProvider::new();
|
||||
let html = r#"
|
||||
<div class="item ">
|
||||
<a href="/videos/336186/sample-video/" title="Sample & Title">
|
||||
<img class="thumb lazy-load" data-original="https://cdn.example/thumb.jpg" />
|
||||
</a>
|
||||
<div class="duration">12:34</div>
|
||||
<div class="views">1.2M</div>
|
||||
</div>
|
||||
<div class="pagination"></div>
|
||||
"#;
|
||||
|
||||
let items = provider.get_video_items_from_html(html.to_string());
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].id, "336186");
|
||||
assert_eq!(items[0].title, "Sample & Title");
|
||||
assert_eq!(
|
||||
items[0].url,
|
||||
"https://viralxxxporn.com/videos/336186/sample-video/"
|
||||
);
|
||||
assert_eq!(items[0].thumb, "https://cdn.example/thumb.jpg");
|
||||
assert_eq!(items[0].duration, 754);
|
||||
assert_eq!(items[0].views, Some(1_200_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_anchor_only_async_markup() {
|
||||
let provider = ViralxxxpornProvider::new();
|
||||
let html = r#"
|
||||
<div class="list-videos">
|
||||
<a href="/video/336186/jax-slayher-teases-her-gorgeous-ebony-ass-in-steamy-video/" title="Jax Slayher Teases Her Gorgeous Ebony Ass In Steamy Video">
|
||||
<img src="https://cdn.example.com/thumb.jpg" />
|
||||
<span class="video-deck">720p 13 min 29K 99%</span>
|
||||
</a>
|
||||
</div>
|
||||
"#;
|
||||
|
||||
let items = provider.get_video_items_from_html(html.to_string());
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].id, "336186");
|
||||
assert_eq!(
|
||||
items[0].url,
|
||||
"https://viralxxxporn.com/video/336186/jax-slayher-teases-her-gorgeous-ebony-ass-in-steamy-video/"
|
||||
);
|
||||
assert_eq!(items[0].thumb, "https://cdn.example.com/thumb.jpg");
|
||||
assert_eq!(items[0].duration, 780);
|
||||
assert_eq!(items[0].views, Some(29_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_real_thumb_url_over_base64_placeholder() {
|
||||
let provider = ViralxxxpornProvider::new();
|
||||
let html = r#"
|
||||
<div class=" th item ">
|
||||
<div class="main-card">
|
||||
<a class="media" href="https://viralxxxporn.com/video/229322/adriana-chechik-kazumi-tease-wet-threesome-fuckfest-video-leaked-993ee5d/" title="Adriana Chechik Kazumi Tease Wet Threesome Fuckfest Video Leaked">
|
||||
<img class="img lazy-load"
|
||||
src="data:image/svg+xml;base64,AAAA"
|
||||
data-original="https://imgcdn.viralxxxporn.com/contents/videos_screenshots/229000/229322/800x450/2.jpg"
|
||||
data-webp="https://imgcdn.viralxxxporn.com/contents/videos_screenshots/229000/229322/800x450/2.jpg"
|
||||
alt="Adriana Chechik Kazumi Tease Wet Threesome Fuckfest Video Leaked">
|
||||
<div class="duration">25:15</div>
|
||||
</a>
|
||||
<div class="content">
|
||||
<ul class="list">
|
||||
<li><span>9.9K Views</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"#;
|
||||
|
||||
let items = provider.get_video_items_from_html(html.to_string());
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(
|
||||
items[0].thumb,
|
||||
"https://imgcdn.viralxxxporn.com/contents/videos_screenshots/229000/229322/800x450/2.jpg"
|
||||
);
|
||||
assert_eq!(items[0].views, Some(9_900));
|
||||
}
|
||||
}
|
||||
1249
src/providers/vrporn.rs
Normal file
342
src/providers/xfree.rs
Normal file
@@ -0,0 +1,342 @@
|
||||
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::videos::{ServerOptions, VideoItem};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use error_chain::error_chain;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::vec;
|
||||
use wreq::Version;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "tiktok",
|
||||
tags: &["tube", "mixed", "search"],
|
||||
};
|
||||
|
||||
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 XfreeProvider {
|
||||
url: String,
|
||||
categories: Arc<RwLock<Vec<FilterOption>>>,
|
||||
}
|
||||
|
||||
impl XfreeProvider {
|
||||
pub fn new() -> Self {
|
||||
let provider = Self {
|
||||
url: "https://www.xfree.com".to_string(),
|
||||
categories: Arc::new(RwLock::new(vec![])),
|
||||
};
|
||||
provider
|
||||
}
|
||||
|
||||
fn build_channel(&self, clientversion: ClientVersion) -> Channel {
|
||||
let _ = clientversion;
|
||||
Channel {
|
||||
id: "xfree".to_string(),
|
||||
name: "XFree".to_string(),
|
||||
description: "Reels & Nudes!".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=xfree.com".to_string(),
|
||||
status: "active".to_string(),
|
||||
categories: self
|
||||
.categories
|
||||
.read()
|
||||
.map(|categories| categories.iter().map(|c| c.title.clone()).collect())
|
||||
.unwrap_or_else(|e| {
|
||||
crate::providers::report_provider_error_background(
|
||||
"xfree",
|
||||
"build_channel.categories_read",
|
||||
&e.to_string(),
|
||||
);
|
||||
vec![]
|
||||
}),
|
||||
options: vec![ChannelOption {
|
||||
id: "sexuality".to_string(),
|
||||
title: "Sexuality".to_string(),
|
||||
description: "Sexuality of the Videos".to_string(),
|
||||
systemImage: "heart".to_string(),
|
||||
colorName: "red".to_string(),
|
||||
multiSelect: false,
|
||||
options: vec![
|
||||
FilterOption {
|
||||
id: "1".to_string(),
|
||||
title: "Straight".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "2".to_string(),
|
||||
title: "Gay".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "3".to_string(),
|
||||
title: "Trans".to_string(),
|
||||
},
|
||||
],
|
||||
}],
|
||||
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 query(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
query: &str,
|
||||
options: ServerOptions,
|
||||
pool: DbPool,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let query = if query.is_empty() { "null" } else { query };
|
||||
let sexuality = match options.clone().sexuality {
|
||||
Some(s) if !s.is_empty() => s,
|
||||
_ => "1".to_string(),
|
||||
};
|
||||
let video_url = format!(
|
||||
"{}/api/2/search?search={}&lgbt={}&limit=30&offset={}",
|
||||
self.url,
|
||||
query.replace(" ", "%20"),
|
||||
sexuality,
|
||||
(page as u32 - 1) * 30
|
||||
);
|
||||
// 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 * 60 * 24 {
|
||||
return Ok(items.clone());
|
||||
} else {
|
||||
let _ = cache.check().await;
|
||||
return Ok(items.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let mut requester =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
// let _ = requester.get("https://www.xfree.com/", Some(Version::HTTP_2)).await;
|
||||
let text = match requester
|
||||
.get_with_headers(
|
||||
&video_url,
|
||||
vec![
|
||||
("Apiversion".to_string(), "1.0".to_string()),
|
||||
(
|
||||
"Accept".to_string(),
|
||||
"application/json text/plain */*".to_string(),
|
||||
),
|
||||
("Referer".to_string(), "https://www.xfree.com/".to_string()),
|
||||
],
|
||||
Some(Version::HTTP_2),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
crate::providers::report_provider_error(
|
||||
"xfree",
|
||||
"query.request",
|
||||
&format!("url={video_url}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
let video_items: Vec<VideoItem> = self
|
||||
.get_video_items_from_json(text.clone(), &mut requester, pool)
|
||||
.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_json(
|
||||
&self,
|
||||
html: String,
|
||||
_requester: &mut Requester,
|
||||
_pool: DbPool,
|
||||
) -> Vec<VideoItem> {
|
||||
let mut items: Vec<VideoItem> = Vec::new();
|
||||
let json_result = serde_json::from_str::<serde_json::Value>(&html);
|
||||
let json = match json_result {
|
||||
Ok(json) => json,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to parse JSON: {e}");
|
||||
crate::providers::report_provider_error(
|
||||
"xfree",
|
||||
"get_video_items_from_json.parse",
|
||||
&format!("Failed to parse JSON: {e}"),
|
||||
)
|
||||
.await;
|
||||
return vec![];
|
||||
}
|
||||
};
|
||||
|
||||
for post in json
|
||||
.get("body")
|
||||
.and_then(|v| v.get("posts"))
|
||||
.and_then(|p| p.as_array())
|
||||
.unwrap_or(&vec![])
|
||||
{
|
||||
let id = post
|
||||
.get("media")
|
||||
.and_then(|v| v.get("name"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
let title = post
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let video_url = format!(
|
||||
"https://cdn.xfree.com/xfree-prod/{}/{}/{}/{}/full.mp4",
|
||||
id.chars().nth(0).unwrap_or('0'),
|
||||
id.chars().nth(1).unwrap_or('0'),
|
||||
id.chars().nth(2).unwrap_or('0'),
|
||||
id
|
||||
);
|
||||
|
||||
let listsuffix = post
|
||||
.get("media")
|
||||
.and_then(|v| v.get("listingSuffix"))
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or_default();
|
||||
let thumb = format!(
|
||||
"https://thumbs.xfree.com/listing/medium/{}_{}.webp",
|
||||
id, listsuffix
|
||||
);
|
||||
let views = post.get("viewCount").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
|
||||
let preview = format!(
|
||||
"https://cdn.xfree.com/xfree-prod/{}/{}/{}/{}/listing7.mp4",
|
||||
id.chars().nth(0).unwrap_or('0'),
|
||||
id.chars().nth(1).unwrap_or('0'),
|
||||
id.chars().nth(2).unwrap_or('0'),
|
||||
id
|
||||
);
|
||||
let duration = post
|
||||
.get("media")
|
||||
.and_then(|v| v.get("duration"))
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or_default() as u32;
|
||||
let tags = post
|
||||
.get("tags")
|
||||
.and_then(|v| v.as_array())
|
||||
.unwrap_or(&vec![])
|
||||
.iter()
|
||||
.filter_map(|t| t.get("tag").and_then(|n| n.as_str()).map(|s| s.to_string()))
|
||||
.collect::<Vec<String>>();
|
||||
for tag in tags.iter() {
|
||||
Self::push_unique(
|
||||
&self.categories,
|
||||
FilterOption {
|
||||
id: tag.clone(),
|
||||
title: tag.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
let uploader = post
|
||||
.get("user")
|
||||
.and_then(|v| v.get("displayName"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let upload_date = post
|
||||
.get("publishedDate")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let uploaded_at = chrono::DateTime::parse_from_rfc3339(&upload_date)
|
||||
.map(|dt| dt.timestamp() as u64)
|
||||
.unwrap_or(0);
|
||||
let aspect_ration = post
|
||||
.get("media")
|
||||
.and_then(|v| v.get("aspectRatio"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
.parse::<f32>()
|
||||
.unwrap_or(0.5625);
|
||||
let video_item = VideoItem::new(
|
||||
id.to_string(),
|
||||
title,
|
||||
video_url,
|
||||
"xfree".to_string(),
|
||||
thumb,
|
||||
duration,
|
||||
)
|
||||
.views(views)
|
||||
.preview(preview)
|
||||
.tags(tags)
|
||||
.uploader(uploader)
|
||||
.uploaded_at(uploaded_at)
|
||||
.aspect_ratio(aspect_ration);
|
||||
items.push(video_item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for XfreeProvider {
|
||||
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 = self
|
||||
.to_owned()
|
||||
.query(
|
||||
cache,
|
||||
page,
|
||||
&query.unwrap_or("null".to_string()),
|
||||
options,
|
||||
pool,
|
||||
)
|
||||
.await;
|
||||
|
||||
res.unwrap_or_else(|e| {
|
||||
eprintln!("xfree error: {e}");
|
||||
vec![]
|
||||
})
|
||||
}
|
||||
|
||||
fn get_channel(&self, v: ClientVersion) -> Option<Channel> {
|
||||
Some(self.build_channel(v))
|
||||
}
|
||||
}
|
||||
440
src/providers/xxdbx.rs
Normal file
@@ -0,0 +1,440 @@
|
||||
use crate::DbPool;
|
||||
use crate::api::ClientVersion;
|
||||
use crate::providers::{Provider, report_provider_error_background};
|
||||
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 regex::Regex;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::vec;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "onlyfans",
|
||||
tags: &["database", "clips", "mixed"],
|
||||
};
|
||||
|
||||
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)
|
||||
match Regex::new(r"^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$") {
|
||||
Ok(re) => re.is_match(s),
|
||||
Err(e) => {
|
||||
report_provider_error_background("xxdbx", "is_valid_date.regex", &e.to_string());
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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 =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
let text = match requester.get(&video_url, None).await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
report_provider_error_background("xxdbx", "get.request", &e.to_string());
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
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()
|
||||
.map(|channels| {
|
||||
channels
|
||||
.iter()
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
.collect::<Vec<String>>()
|
||||
.contains(&search_string.to_ascii_lowercase())
|
||||
})
|
||||
.unwrap_or(false)
|
||||
{
|
||||
search_type = "channels";
|
||||
} else if self
|
||||
.stars
|
||||
.read()
|
||||
.map(|stars| {
|
||||
stars
|
||||
.iter()
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
.collect::<Vec<String>>()
|
||||
.contains(&search_string.to_ascii_lowercase())
|
||||
})
|
||||
.unwrap_or(false)
|
||||
{
|
||||
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 =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
|
||||
let text = match requester.get(&video_url, None).await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
report_provider_error_background("xxdbx", "query.request", &e.to_string());
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
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>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<div class=\"vids\">")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
);
|
||||
let mut title = video_segment
|
||||
.split("<div class=\"v_title\">")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
// html decode
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
let id = video_url
|
||||
.split("/")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(4)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
let thumb = format!(
|
||||
"https:{}",
|
||||
video_segment
|
||||
.split("<img ")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("src=\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.last()
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
);
|
||||
let raw_duration = video_segment
|
||||
.split("<div class=\"v_dur\">")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
);
|
||||
let tags = video_segment
|
||||
.split("<div class=\"v_tags\">")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("</div>")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<a href=\"")
|
||||
.collect::<Vec<&str>>()[1..]
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
s.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.replace("%20", " ")
|
||||
.to_string()
|
||||
})
|
||||
.collect::<Vec<String>>();
|
||||
for tag in tags.clone() {
|
||||
let shorted_tag = tag
|
||||
.split("/")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(2)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
if tag.contains("channels")
|
||||
&& self
|
||||
.channels
|
||||
.read()
|
||||
.map(|channels| !channels.contains(&shorted_tag))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
if let Ok(mut channels) = self.channels.write() {
|
||||
channels.push(shorted_tag.clone());
|
||||
}
|
||||
}
|
||||
if tag.contains("stars")
|
||||
&& self
|
||||
.stars
|
||||
.read()
|
||||
.map(|stars| !stars.contains(&shorted_tag))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
if let Ok(mut stars) = self.stars.write() {
|
||||
stars.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()
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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))
|
||||
}
|
||||
}
|
||||
379
src/providers/xxthots.rs
Normal file
@@ -0,0 +1,379 @@
|
||||
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, VideoFormat, VideoItem};
|
||||
use async_trait::async_trait;
|
||||
use error_chain::error_chain;
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
use std::vec;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "onlyfans",
|
||||
tags: &["onlyfans", "leaks", "creator"],
|
||||
};
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
|
||||
Channel {
|
||||
id: "xxthots".to_string(),
|
||||
name: "XXTHOTS".to_string(),
|
||||
description: "Free XXX Onlyfans Leaks Videos".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=xxthots.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".to_string(),
|
||||
title: "New".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "popular".to_string(),
|
||||
title: "Popular".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "top-rated".to_string(),
|
||||
title: "Top Rated".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 (sort_path, list_str, sort_by) = match sort {
|
||||
"popular" => (
|
||||
"/most-popular/",
|
||||
"list_videos_common_videos_list",
|
||||
"video_viewed",
|
||||
),
|
||||
"top-rated" => ("/top-rated/", "list_videos_common_videos_list", "rating"),
|
||||
_ => (
|
||||
"/latest-updates/",
|
||||
"list_videos_latest_videos_list",
|
||||
"post_date",
|
||||
),
|
||||
};
|
||||
let video_url = format!(
|
||||
"{}{}?mode=async&function=get_block&block_id={}&sort_by={}&from={}",
|
||||
self.url, sort_path, list_str, sort_by, 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 =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
let text = match requester.get(&video_url, None).await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
crate::providers::report_provider_error(
|
||||
"xxthots",
|
||||
"get.request",
|
||||
&format!("url={video_url}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
if text.trim().is_empty() {
|
||||
crate::providers::report_provider_error(
|
||||
"xxthots",
|
||||
"get.empty_response",
|
||||
&format!("url={video_url}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
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 =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
let text = match requester.get(&video_url, None).await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
crate::providers::report_provider_error(
|
||||
"xxthots",
|
||||
"query.request",
|
||||
&format!("url={video_url}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
if text.trim().is_empty() {
|
||||
crate::providers::report_provider_error(
|
||||
"xxthots",
|
||||
"query.empty_response",
|
||||
&format!("url={video_url}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
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: Vec<&str> = html
|
||||
.split("<div class=\"pagination\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<div class=\"thumb thumb_rel item \">")
|
||||
.skip(1)
|
||||
.collect();
|
||||
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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let mut title = video_segment
|
||||
.split("\" title=\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
// html decode
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
let id = video_url
|
||||
.split("/")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(4)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let raw_duration = video_segment
|
||||
.split("<div class=\"time\">")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("data-original=\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
let views_part = video_segment
|
||||
.split("svg-icon icon-eye")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("</i>")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let views = parse_abbreviated_number(&views_part).unwrap_or(0) as u32;
|
||||
let preview = video_segment
|
||||
.split("data-preview=\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split('"')
|
||||
.collect::<Vec<&str>>()
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
let mut video_item = VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
video_url.to_string(),
|
||||
"xxthots".to_string(),
|
||||
thumb,
|
||||
duration,
|
||||
)
|
||||
.views(views);
|
||||
if !preview.is_empty() {
|
||||
let mut format = VideoFormat::new(
|
||||
preview.clone(),
|
||||
"preview".to_string(),
|
||||
"video/mp4".to_string(),
|
||||
);
|
||||
format.add_http_header("Referer".to_string(), video_url.clone());
|
||||
video_item = video_item.preview(preview).formats(vec![format]);
|
||||
}
|
||||
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 _ = per_page;
|
||||
let _ = pool;
|
||||
let videos: std::result::Result<Vec<VideoItem>, Error> = match query {
|
||||
Some(q) if !q.trim().is_empty() => {
|
||||
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
|
||||
}
|
||||
_ => {
|
||||
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> {
|
||||
Some(self.build_channel(clientversion))
|
||||
}
|
||||
}
|
||||
1516
src/providers/yesporn.rs
Normal file
363
src/providers/youjizz.rs
Normal file
@@ -0,0 +1,363 @@
|
||||
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::vec;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "mainstream-tube",
|
||||
tags: &["mainstream", "mixed", "search"],
|
||||
};
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
|
||||
Channel {
|
||||
id: "youjizz".to_string(),
|
||||
name: "YouJizz".to_string(),
|
||||
description: "YouJizz Porntube".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=www.youjizz.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".to_string(),
|
||||
title: "New".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "popular".to_string(),
|
||||
title: "Popular".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "top-rated".to_string(),
|
||||
title: "Top Rated".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "top-rated-week".to_string(),
|
||||
title: "Top Rated (Week)".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "top-rated-month".to_string(),
|
||||
title: "Top Rated (Month)".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "trending".to_string(),
|
||||
title: "Trending".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "random".to_string(),
|
||||
title: "Random".to_string(),
|
||||
},
|
||||
],
|
||||
multiSelect: false,
|
||||
}],
|
||||
nsfw: true,
|
||||
cacheDuration: None,
|
||||
}
|
||||
}
|
||||
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 =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
let text = match requester.get(&video_url, None).await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
crate::providers::report_provider_error(
|
||||
"youjizz",
|
||||
"get.request",
|
||||
&format!("url={video_url}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
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 =
|
||||
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
|
||||
let text = match requester.get(&video_url, None).await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
crate::providers::report_provider_error(
|
||||
"youjizz",
|
||||
"query.request",
|
||||
&format!("url={video_url}; error={e}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
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>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
);
|
||||
let mut title = video_segment
|
||||
.split("class=\"video-title\">")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split(">")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
// html decode
|
||||
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
||||
let id = video_url
|
||||
.split("/")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(4)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
let thumb = format!(
|
||||
"https:{}",
|
||||
video_segment
|
||||
.split("<img ")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("data-original=\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("\"")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
);
|
||||
let raw_duration = video_segment
|
||||
.split("fa fa-clock-o\"></i> ")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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>>()
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.split("<")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(0)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.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![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
||||
Some(self.build_channel(clientversion))
|
||||
}
|
||||
}
|
||||
406
src/proxies/doodstream.rs
Normal file
@@ -0,0 +1,406 @@
|
||||
use ntex::web;
|
||||
use regex::{Captures, Regex};
|
||||
use url::Url;
|
||||
|
||||
use crate::util::requester::Requester;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DoodstreamProxy {}
|
||||
|
||||
impl DoodstreamProxy {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
|
||||
fn normalize_detail_url(endpoint: &str) -> Option<String> {
|
||||
let normalized = if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
|
||||
endpoint.trim().to_string()
|
||||
} else {
|
||||
format!("https://{}", endpoint.trim_start_matches('/'))
|
||||
};
|
||||
|
||||
Self::is_allowed_detail_url(&normalized).then_some(normalized)
|
||||
}
|
||||
|
||||
fn is_allowed_host(host: &str) -> bool {
|
||||
matches!(
|
||||
host,
|
||||
"turboplayers.xyz"
|
||||
| "www.turboplayers.xyz"
|
||||
| "trailerhg.xyz"
|
||||
| "www.trailerhg.xyz"
|
||||
| "streamhg.com"
|
||||
| "www.streamhg.com"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_allowed_detail_url(url: &str) -> bool {
|
||||
let Some(url) = Url::parse(url).ok() else {
|
||||
return false;
|
||||
};
|
||||
if url.scheme() != "https" {
|
||||
return false;
|
||||
}
|
||||
let Some(host) = url.host_str() else {
|
||||
return false;
|
||||
};
|
||||
if !Self::is_allowed_host(host) {
|
||||
return false;
|
||||
}
|
||||
|
||||
url.path().starts_with("/t/")
|
||||
|| url.path().starts_with("/e/")
|
||||
|| url.path().starts_with("/d/")
|
||||
}
|
||||
|
||||
fn request_origin(detail_url: &str) -> Option<String> {
|
||||
let parsed = Url::parse(detail_url).ok()?;
|
||||
let host = parsed.host_str()?;
|
||||
Some(format!("{}://{}", parsed.scheme(), host))
|
||||
}
|
||||
|
||||
fn request_headers(detail_url: &str) -> Vec<(String, String)> {
|
||||
let origin = Self::request_origin(detail_url)
|
||||
.unwrap_or_else(|| "https://turboplayers.xyz".to_string());
|
||||
vec![
|
||||
(
|
||||
"Referer".to_string(),
|
||||
format!("{}/", origin.trim_end_matches('/')),
|
||||
),
|
||||
("Origin".to_string(), origin),
|
||||
(
|
||||
"Accept".to_string(),
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8".to_string(),
|
||||
),
|
||||
("Accept-Language".to_string(), "en-US,en;q=0.9".to_string()),
|
||||
("Sec-Fetch-Site".to_string(), "same-origin".to_string()),
|
||||
]
|
||||
}
|
||||
|
||||
fn regex(pattern: &str) -> Option<Regex> {
|
||||
Regex::new(pattern).ok()
|
||||
}
|
||||
|
||||
fn decode_base36(token: &str) -> Option<usize> {
|
||||
usize::from_str_radix(token, 36).ok()
|
||||
}
|
||||
|
||||
fn sanitize_media_url(url: &str) -> String {
|
||||
url.trim()
|
||||
.trim_end_matches('\\')
|
||||
.trim_end_matches('"')
|
||||
.trim_end_matches('\'')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn extract_literal_url(text: &str) -> Option<String> {
|
||||
let direct_patterns = [
|
||||
r#"urlPlay\s*=\s*'(?P<url>https?://[^']+)'"#,
|
||||
r#"data-hash\s*=\s*"(?P<url>https?://[^"]+)""#,
|
||||
r#""(?P<url>https?://[^"]+\.(?:m3u8|mp4)(?:\?[^"]*)?)""#,
|
||||
r#"'(?P<url>https?://[^']+\.(?:m3u8|mp4)(?:\?[^']*)?)'"#,
|
||||
];
|
||||
|
||||
for pattern in direct_patterns {
|
||||
let Some(regex) = Self::regex(pattern) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(url) = regex
|
||||
.captures(text)
|
||||
.and_then(|captures| captures.name("url"))
|
||||
.map(|value| Self::sanitize_media_url(value.as_str()))
|
||||
{
|
||||
return Some(url);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_packed_eval_args(text: &str) -> Option<(String, usize, usize, Vec<String>)> {
|
||||
let regex = Self::regex(
|
||||
r#"eval\(function\(p,a,c,k,e,d\)\{.*?\}\('(?P<payload>(?:\\'|\\\\|[^'])*)',(?P<radix>\d+),(?P<count>\d+),'(?P<symbols>(?:\\'|\\\\|[^'])*)'\.split\('\|'\)"#,
|
||||
)?;
|
||||
let captures = regex.captures(text)?;
|
||||
let payload = Self::decode_js_single_quoted(captures.name("payload")?.as_str());
|
||||
let radix = captures.name("radix")?.as_str().parse::<usize>().ok()?;
|
||||
let count = captures.name("count")?.as_str().parse::<usize>().ok()?;
|
||||
let symbols = Self::decode_js_single_quoted(captures.name("symbols")?.as_str());
|
||||
let parts = symbols.split('|').map(|value| value.to_string()).collect();
|
||||
Some((payload, radix, count, parts))
|
||||
}
|
||||
|
||||
fn decode_js_single_quoted(value: &str) -> String {
|
||||
let mut result = String::with_capacity(value.len());
|
||||
let mut chars = value.chars();
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch != '\\' {
|
||||
result.push(ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
match chars.next() {
|
||||
Some('\\') => result.push('\\'),
|
||||
Some('\'') => result.push('\''),
|
||||
Some('"') => result.push('"'),
|
||||
Some('n') => result.push('\n'),
|
||||
Some('r') => result.push('\r'),
|
||||
Some('t') => result.push('\t'),
|
||||
Some(other) => {
|
||||
result.push('\\');
|
||||
result.push(other);
|
||||
}
|
||||
None => result.push('\\'),
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn unpack_packer(text: &str) -> Option<String> {
|
||||
let (mut payload, radix, count, symbols) = Self::extract_packed_eval_args(text)?;
|
||||
if radix != 36 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let token_regex = Self::regex(r"\b[0-9a-z]+\b")?;
|
||||
payload = token_regex
|
||||
.replace_all(&payload, |captures: &Captures| {
|
||||
let token = captures
|
||||
.get(0)
|
||||
.map(|value| value.as_str())
|
||||
.unwrap_or_default();
|
||||
let Some(index) = Self::decode_base36(token) else {
|
||||
return token.to_string();
|
||||
};
|
||||
if index >= count {
|
||||
return token.to_string();
|
||||
}
|
||||
let replacement = symbols.get(index).map(|value| value.as_str()).unwrap_or("");
|
||||
if replacement.is_empty() {
|
||||
token.to_string()
|
||||
} else {
|
||||
replacement.to_string()
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
Some(payload)
|
||||
}
|
||||
|
||||
fn collect_media_candidates(text: &str) -> Vec<String> {
|
||||
let Some(regex) = Self::regex(r#"https?://[^\s"'<>]+?\.(?:m3u8|mp4|txt)(?:\?[^\s"'<>]*)?"#)
|
||||
else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let mut urls = regex
|
||||
.find_iter(text)
|
||||
.map(|value| Self::sanitize_media_url(value.as_str()))
|
||||
.filter(|url| url.starts_with("https://"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
urls.sort_by_key(|url| {
|
||||
if url.contains(".m3u8") {
|
||||
0
|
||||
} else if url.contains(".mp4") {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
}
|
||||
});
|
||||
urls.dedup();
|
||||
urls
|
||||
}
|
||||
|
||||
fn extract_stream_url(text: &str) -> Option<String> {
|
||||
if let Some(url) = Self::extract_literal_url(text) {
|
||||
return Some(url);
|
||||
}
|
||||
|
||||
let unpacked = Self::unpack_packer(text)?;
|
||||
Self::collect_media_candidates(&unpacked)
|
||||
.into_iter()
|
||||
.next()
|
||||
.or_else(|| Self::extract_literal_url(&unpacked))
|
||||
}
|
||||
|
||||
fn extract_pass_md5_url(text: &str, detail_url: &str) -> Option<String> {
|
||||
let decoded = text.replace("\\/", "/");
|
||||
let absolute_regex = Self::regex(r#"https?://[^\s"'<>]+/pass_md5/[^\s"'<>]+"#)?;
|
||||
if let Some(url) = absolute_regex
|
||||
.find(&decoded)
|
||||
.map(|value| value.as_str().to_string())
|
||||
{
|
||||
return Some(url);
|
||||
}
|
||||
|
||||
let relative_regex = Self::regex(r#"/pass_md5/[^\s"'<>]+"#)?;
|
||||
let relative = relative_regex.find(&decoded)?.as_str();
|
||||
let origin = Self::request_origin(detail_url)?;
|
||||
Some(format!("{origin}{relative}"))
|
||||
}
|
||||
|
||||
fn compose_pass_md5_media_url(pass_md5_url: &str, response_body: &str) -> Option<String> {
|
||||
let raw = response_body
|
||||
.trim()
|
||||
.trim_matches('"')
|
||||
.trim_matches('\'')
|
||||
.replace("\\/", "/");
|
||||
if raw.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut media_url = if raw.starts_with("https://") || raw.starts_with("http://") {
|
||||
raw
|
||||
} else if let Some(rest) = raw.strip_prefix("//") {
|
||||
format!("https://{rest}")
|
||||
} else {
|
||||
let parsed = Url::parse(pass_md5_url).ok()?;
|
||||
let host = parsed.host_str()?;
|
||||
format!("{}://{}{}", parsed.scheme(), host, raw)
|
||||
};
|
||||
|
||||
let query = Url::parse(pass_md5_url)
|
||||
.ok()
|
||||
.and_then(|url| url.query().map(str::to_string));
|
||||
if let Some(query) = query {
|
||||
if !query.is_empty() && !media_url.contains("token=") {
|
||||
let separator = if media_url.contains('?') { '&' } else { '?' };
|
||||
media_url.push(separator);
|
||||
media_url.push_str(&query);
|
||||
}
|
||||
}
|
||||
|
||||
Some(Self::sanitize_media_url(&media_url))
|
||||
}
|
||||
|
||||
async fn resolve_stream_from_pass_md5(
|
||||
detail_url: &str,
|
||||
html: &str,
|
||||
requester: &mut Requester,
|
||||
) -> Option<String> {
|
||||
let pass_md5_url = Self::extract_pass_md5_url(html, detail_url).or_else(|| {
|
||||
Self::unpack_packer(html)
|
||||
.and_then(|unpacked| Self::extract_pass_md5_url(&unpacked, detail_url))
|
||||
})?;
|
||||
|
||||
let headers = vec![
|
||||
("Referer".to_string(), detail_url.to_string()),
|
||||
("X-Requested-With".to_string(), "XMLHttpRequest".to_string()),
|
||||
("Accept".to_string(), "*/*".to_string()),
|
||||
];
|
||||
let response = requester
|
||||
.get_with_headers(&pass_md5_url, headers, None)
|
||||
.await
|
||||
.ok()?;
|
||||
Self::compose_pass_md5_media_url(&pass_md5_url, &response)
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::proxies::Proxy for DoodstreamProxy {
|
||||
async fn get_video_url(&self, url: String, requester: web::types::State<Requester>) -> String {
|
||||
let Some(detail_url) = Self::normalize_detail_url(&url) else {
|
||||
return String::new();
|
||||
};
|
||||
|
||||
let mut requester = requester.get_ref().clone();
|
||||
let html = match requester
|
||||
.get_with_headers(&detail_url, Self::request_headers(&detail_url), None)
|
||||
.await
|
||||
{
|
||||
Ok(text) => text,
|
||||
Err(_) => return String::new(),
|
||||
};
|
||||
|
||||
if let Some(url) = Self::extract_stream_url(&html) {
|
||||
return url;
|
||||
}
|
||||
|
||||
if let Some(url) =
|
||||
Self::resolve_stream_from_pass_md5(&detail_url, &html, &mut requester).await
|
||||
{
|
||||
return url;
|
||||
}
|
||||
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::DoodstreamProxy;
|
||||
|
||||
#[test]
|
||||
fn allows_only_known_doodstream_hosts() {
|
||||
assert!(DoodstreamProxy::is_allowed_detail_url(
|
||||
"https://turboplayers.xyz/t/69bdfb21cc640"
|
||||
));
|
||||
assert!(DoodstreamProxy::is_allowed_detail_url(
|
||||
"https://trailerhg.xyz/e/ttdc7a6qpskt"
|
||||
));
|
||||
assert!(!DoodstreamProxy::is_allowed_detail_url(
|
||||
"http://turboplayers.xyz/t/69bdfb21cc640"
|
||||
));
|
||||
assert!(!DoodstreamProxy::is_allowed_detail_url(
|
||||
"https://example.com/t/69bdfb21cc640"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_clear_hls_url_from_turboplayers_layout() {
|
||||
let html = r#"
|
||||
<div id="video_player" data-hash="https://cdn4.turboviplay.com/data1/69bdfa8ce1f4d/69bdfa8ce1f4d.m3u8"></div>
|
||||
<script>
|
||||
var urlPlay = 'https://cdn4.turboviplay.com/data1/69bdfa8ce1f4d/69bdfa8ce1f4d.m3u8';
|
||||
</script>
|
||||
"#;
|
||||
|
||||
assert_eq!(
|
||||
DoodstreamProxy::extract_stream_url(html).as_deref(),
|
||||
Some("https://cdn4.turboviplay.com/data1/69bdfa8ce1f4d/69bdfa8ce1f4d.m3u8")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unpacks_streamhg_style_player_config() {
|
||||
let html = r#"
|
||||
<script type='text/javascript'>
|
||||
eval(function(p,a,c,k,e,d){while(c--)if(k[c])p=p.replace(new RegExp('\\b'+c.toString(a)+'\\b','g'),k[c]);return p}('0 1={\"2\":\"https://cdn.example/master.m3u8?t=1\",\"3\":\"https://cdn.example/master.txt\"};4(\"5\").6({7:[{8:1.2,9:\"a\"}]});',36,11,'var|links|hls2|hls3|jwplayer|vplayer|setup|sources|file|type|hls'.split('|')))
|
||||
</script>
|
||||
"#;
|
||||
|
||||
assert_eq!(
|
||||
DoodstreamProxy::extract_stream_url(html).as_deref(),
|
||||
Some("https://cdn.example/master.m3u8?t=1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composes_media_url_from_pass_md5_response() {
|
||||
let pass_md5_url =
|
||||
"https://trailerhg.xyz/pass_md5/abc123/def456?token=t0k3n&expiry=1775000000";
|
||||
let body = "https://g4vsrqvtrj.pinebrookproductionlab.shop/1ghkpx2e8jnal/hls3/01/08534/syyzvotfnhaa_l/master.txt";
|
||||
assert_eq!(
|
||||
DoodstreamProxy::compose_pass_md5_media_url(pass_md5_url, body).as_deref(),
|
||||
Some(
|
||||
"https://g4vsrqvtrj.pinebrookproductionlab.shop/1ghkpx2e8jnal/hls3/01/08534/syyzvotfnhaa_l/master.txt?token=t0k3n&expiry=1775000000"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_relative_pass_md5_url() {
|
||||
let html = r#"
|
||||
<script>
|
||||
var file = "/pass_md5/abc123/def456?token=t0k3n&expiry=1775000000";
|
||||
</script>
|
||||
"#;
|
||||
assert_eq!(
|
||||
DoodstreamProxy::extract_pass_md5_url(html, "https://trailerhg.xyz/e/ttdc7a6qpskt")
|
||||
.as_deref(),
|
||||
Some("https://trailerhg.xyz/pass_md5/abc123/def456?token=t0k3n&expiry=1775000000")
|
||||
);
|
||||
}
|
||||
}
|
||||
86
src/proxies/hanimecdn.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
use ntex::http::header::{CONTENT_LENGTH, CONTENT_TYPE};
|
||||
use ntex::{
|
||||
http::Response,
|
||||
web::{self, HttpRequest, error},
|
||||
};
|
||||
|
||||
use crate::util::requester::Requester;
|
||||
|
||||
fn normalize_image_url(endpoint: &str) -> String {
|
||||
let endpoint = endpoint.trim_start_matches('/');
|
||||
if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
|
||||
endpoint.to_string()
|
||||
} else if endpoint.starts_with("hanime-cdn.com/") || endpoint == "hanime-cdn.com" {
|
||||
format!("https://{endpoint}")
|
||||
} else {
|
||||
format!("https://{endpoint}")
|
||||
}
|
||||
}
|
||||
|
||||
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 = normalize_image_url(&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()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::normalize_image_url;
|
||||
|
||||
#[test]
|
||||
fn keeps_full_hanime_cdn_host_path_without_duplication() {
|
||||
assert_eq!(
|
||||
normalize_image_url("hanime-cdn.com/images/covers/natsu-zuma-2-cv1.png"),
|
||||
"https://hanime-cdn.com/images/covers/natsu-zuma-2-cv1.png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefixes_relative_paths_with_hanime_cdn_host() {
|
||||
assert_eq!(
|
||||
normalize_image_url("/images/covers/natsu-zuma-2-cv1.png"),
|
||||
"https://hanime-cdn.com/images/covers/natsu-zuma-2-cv1.png"
|
||||
);
|
||||
}
|
||||
}
|
||||
51
src/proxies/hqpornerthumb.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use ntex::http::header::{CONTENT_LENGTH, CONTENT_TYPE};
|
||||
use ntex::{
|
||||
http::Response,
|
||||
web::{self, HttpRequest, error},
|
||||
};
|
||||
|
||||
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 = if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
|
||||
endpoint
|
||||
} else {
|
||||
format!("https://{}", endpoint.trim_start_matches('/'))
|
||||
};
|
||||
|
||||
let upstream = match requester
|
||||
.get_ref()
|
||||
.clone()
|
||||
.get_raw_with_headers(
|
||||
image_url.as_str(),
|
||||
vec![("Referer".to_string(), "https://hqporner.com/".to_string())],
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(_) => return Ok(web::HttpResponse::NotFound().finish()),
|
||||
};
|
||||
|
||||
let status = upstream.status();
|
||||
let headers = upstream.headers().clone();
|
||||
let bytes = upstream.bytes().await.map_err(error::ErrorBadGateway)?;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(resp.body(bytes.to_vec()))
|
||||
}
|
||||
69
src/proxies/javtiful.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
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 endpoint = url
|
||||
.trim_start_matches('/')
|
||||
.strip_prefix("https://")
|
||||
.or_else(|| url.trim_start_matches('/').strip_prefix("http://"))
|
||||
.unwrap_or(url.trim_start_matches('/'))
|
||||
.trim_start_matches("www.javtiful.com/")
|
||||
.trim_start_matches("javtiful.com/")
|
||||
.trim_start_matches('/')
|
||||
.to_string();
|
||||
let detail_url = format!("https://javtiful.com/{endpoint}");
|
||||
let text = requester.get(&detail_url, None).await.unwrap_or_default();
|
||||
if text.is_empty() {
|
||||
return "".to_string();
|
||||
}
|
||||
let video_id = endpoint.split('/').nth(1).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(), detail_url)],
|
||||
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;
|
||||
}
|
||||
}
|
||||
51
src/proxies/mod.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use crate::proxies::doodstream::DoodstreamProxy;
|
||||
use crate::proxies::pornhd3x::Pornhd3xProxy;
|
||||
use ntex::web;
|
||||
|
||||
use crate::proxies::pimpbunny::PimpbunnyProxy;
|
||||
use crate::proxies::porndish::PorndishProxy;
|
||||
use crate::proxies::spankbang::SpankbangProxy;
|
||||
use crate::{proxies::sxyprn::SxyprnProxy, util::requester::Requester};
|
||||
|
||||
pub mod doodstream;
|
||||
pub mod hanimecdn;
|
||||
pub mod hqpornerthumb;
|
||||
pub mod javtiful;
|
||||
pub mod noodlemagazine;
|
||||
pub mod pimpbunny;
|
||||
pub mod pimpbunnythumb;
|
||||
pub mod porndish;
|
||||
pub mod porndishthumb;
|
||||
pub mod pornhd3x;
|
||||
pub mod shooshtime;
|
||||
pub mod spankbang;
|
||||
pub mod sxyprn;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AnyProxy {
|
||||
Doodstream(DoodstreamProxy),
|
||||
Sxyprn(SxyprnProxy),
|
||||
Javtiful(javtiful::JavtifulProxy),
|
||||
Pornhd3x(Pornhd3xProxy),
|
||||
Pimpbunny(PimpbunnyProxy),
|
||||
Porndish(PorndishProxy),
|
||||
Spankbang(SpankbangProxy),
|
||||
}
|
||||
|
||||
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::Doodstream(p) => p.get_video_url(url, requester).await,
|
||||
AnyProxy::Sxyprn(p) => p.get_video_url(url, requester).await,
|
||||
AnyProxy::Javtiful(p) => p.get_video_url(url, requester).await,
|
||||
AnyProxy::Pornhd3x(p) => p.get_video_url(url, requester).await,
|
||||
AnyProxy::Pimpbunny(p) => p.get_video_url(url, requester).await,
|
||||
AnyProxy::Porndish(p) => p.get_video_url(url, requester).await,
|
||||
AnyProxy::Spankbang(p) => p.get_video_url(url, requester).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
441
src/proxies/noodlemagazine.rs
Normal file
@@ -0,0 +1,441 @@
|
||||
use ntex::http::header::{CONTENT_LENGTH, CONTENT_TYPE};
|
||||
use ntex::{
|
||||
http::Response,
|
||||
web::{self, HttpRequest, error},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::net::IpAddr;
|
||||
use url::Url;
|
||||
use wreq::Version;
|
||||
|
||||
use crate::util::requester::Requester;
|
||||
|
||||
const FIREFOX_USER_AGENT: &str =
|
||||
"Mozilla/5.0 (X11; Linux x86_64; rv:147.0) Gecko/20100101 Firefox/147.0";
|
||||
const HTML_ACCEPT: &str =
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8";
|
||||
const IMAGE_ACCEPT: &str = "image/avif,image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NoodlemagazineProxy {}
|
||||
|
||||
impl NoodlemagazineProxy {
|
||||
pub fn new() -> Self {
|
||||
NoodlemagazineProxy {}
|
||||
}
|
||||
|
||||
fn extract_playlist(text: &str) -> Option<&str> {
|
||||
text.split("window.playlist = ").nth(1)?.split(';').next()
|
||||
}
|
||||
|
||||
fn source_score(source: &Value) -> (u8, u32) {
|
||||
let file = source["file"].as_str().unwrap_or_default();
|
||||
let label = source["label"].as_str().unwrap_or_default();
|
||||
let is_hls = u8::from(file.contains(".m3u8"));
|
||||
let quality = label
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_digit())
|
||||
.collect::<String>()
|
||||
.parse::<u32>()
|
||||
.unwrap_or(0);
|
||||
|
||||
(is_hls, quality)
|
||||
}
|
||||
|
||||
fn select_best_source(playlist: &str) -> Option<String> {
|
||||
let json: Value = serde_json::from_str(playlist).ok()?;
|
||||
let sources = json["sources"].as_array()?;
|
||||
|
||||
sources
|
||||
.iter()
|
||||
.filter(|source| {
|
||||
source["file"]
|
||||
.as_str()
|
||||
.map(|file| !file.is_empty())
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.max_by_key(|source| Self::source_score(source))
|
||||
.and_then(|source| source["file"].as_str())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn normalize_video_page_url(url: &str) -> String {
|
||||
if url.starts_with("http://") || url.starts_with("https://") {
|
||||
url.to_string()
|
||||
} else {
|
||||
format!("https://{}", url.trim_start_matches('/'))
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_image_url(url: &str) -> String {
|
||||
if url.starts_with("http://") || url.starts_with("https://") {
|
||||
url.to_string()
|
||||
} else {
|
||||
format!("https://{}", url.trim_start_matches('/'))
|
||||
}
|
||||
}
|
||||
|
||||
fn root_referer() -> &'static str {
|
||||
"https://noodlemagazine.com/"
|
||||
}
|
||||
|
||||
fn root_html_headers() -> Vec<(String, String)> {
|
||||
vec![
|
||||
("Referer".to_string(), Self::root_referer().to_string()),
|
||||
("User-Agent".to_string(), FIREFOX_USER_AGENT.to_string()),
|
||||
("Accept".to_string(), HTML_ACCEPT.to_string()),
|
||||
("Accept-Language".to_string(), "en-US,en;q=0.9".to_string()),
|
||||
]
|
||||
}
|
||||
|
||||
fn image_headers(requester: &Requester, image_url: &str) -> Vec<(String, String)> {
|
||||
let mut headers = vec![
|
||||
("Referer".to_string(), Self::root_referer().to_string()),
|
||||
("User-Agent".to_string(), FIREFOX_USER_AGENT.to_string()),
|
||||
("Accept".to_string(), IMAGE_ACCEPT.to_string()),
|
||||
("Accept-Language".to_string(), "en-US,en;q=0.9".to_string()),
|
||||
];
|
||||
|
||||
if let Some(cookie) = requester.cookie_header_for_url(image_url) {
|
||||
headers.push(("Cookie".to_string(), cookie));
|
||||
}
|
||||
|
||||
headers
|
||||
}
|
||||
|
||||
fn has_allowed_image_extension(path: &str) -> bool {
|
||||
let path = path.to_ascii_lowercase();
|
||||
[".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif"]
|
||||
.iter()
|
||||
.any(|ext| path.ends_with(ext))
|
||||
}
|
||||
|
||||
fn is_disallowed_thumb_host(host: &str) -> bool {
|
||||
if host.eq_ignore_ascii_case("localhost") {
|
||||
return true;
|
||||
}
|
||||
|
||||
match host.parse::<IpAddr>() {
|
||||
Ok(IpAddr::V4(ip)) => {
|
||||
ip.is_private()
|
||||
|| ip.is_loopback()
|
||||
|| ip.is_link_local()
|
||||
|| ip.is_broadcast()
|
||||
|| ip.is_documentation()
|
||||
|| ip.is_unspecified()
|
||||
}
|
||||
Ok(IpAddr::V6(ip)) => {
|
||||
ip.is_loopback()
|
||||
|| ip.is_unspecified()
|
||||
|| ip.is_multicast()
|
||||
|| ip.is_unique_local()
|
||||
|| ip.is_unicast_link_local()
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_allowed_thumb_url(url: &str) -> bool {
|
||||
let Some(url) = Url::parse(url).ok() else {
|
||||
return false;
|
||||
};
|
||||
if url.scheme() != "https" {
|
||||
return false;
|
||||
}
|
||||
let Some(host) = url.host_str() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
!Self::is_disallowed_thumb_host(host) && Self::has_allowed_image_extension(url.path())
|
||||
}
|
||||
|
||||
fn is_binary_image_content_type(content_type: &str) -> bool {
|
||||
let media_type = content_type
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
|
||||
media_type.starts_with("image/")
|
||||
}
|
||||
|
||||
fn is_hls_url(url: &str) -> bool {
|
||||
Url::parse(url)
|
||||
.ok()
|
||||
.map(|parsed| parsed.path().ends_with(".m3u8"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn absolutize_uri(base_url: &Url, value: &str) -> String {
|
||||
if value.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
if value.starts_with('#')
|
||||
|| value.starts_with("data:")
|
||||
|| value.starts_with("http://")
|
||||
|| value.starts_with("https://")
|
||||
{
|
||||
return value.to_string();
|
||||
}
|
||||
|
||||
base_url
|
||||
.join(value)
|
||||
.map(|url| url.to_string())
|
||||
.unwrap_or_else(|_| value.to_string())
|
||||
}
|
||||
|
||||
fn rewrite_manifest_line(base_url: &Url, line: &str) -> String {
|
||||
if line.trim().is_empty() {
|
||||
return line.to_string();
|
||||
}
|
||||
|
||||
if !line.starts_with('#') {
|
||||
return Self::absolutize_uri(base_url, line);
|
||||
}
|
||||
|
||||
let Some(uri_start) = line.find("URI=\"") else {
|
||||
return line.to_string();
|
||||
};
|
||||
let value_start = uri_start + 5;
|
||||
let Some(relative_end) = line[value_start..].find('"') else {
|
||||
return line.to_string();
|
||||
};
|
||||
let value_end = value_start + relative_end;
|
||||
let value = &line[value_start..value_end];
|
||||
let rewritten = Self::absolutize_uri(base_url, value);
|
||||
|
||||
format!(
|
||||
"{}{}{}",
|
||||
&line[..value_start],
|
||||
rewritten,
|
||||
&line[value_end..]
|
||||
)
|
||||
}
|
||||
|
||||
fn rewrite_manifest(manifest_url: &str, body: &str) -> Option<String> {
|
||||
let base_url = Url::parse(manifest_url).ok()?;
|
||||
|
||||
Some(
|
||||
body.lines()
|
||||
.map(|line| Self::rewrite_manifest_line(&base_url, line))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
)
|
||||
}
|
||||
|
||||
async fn resolve_source_url(
|
||||
&self,
|
||||
url: String,
|
||||
requester: web::types::State<Requester>,
|
||||
) -> Option<(String, String)> {
|
||||
let mut requester = requester.get_ref().clone();
|
||||
let url = Self::normalize_video_page_url(&url);
|
||||
let text = requester
|
||||
.get(&url, Some(Version::HTTP_2))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let Some(playlist) = Self::extract_playlist(&text) else {
|
||||
return None;
|
||||
};
|
||||
|
||||
Self::select_best_source(playlist).map(|source_url| (url, source_url))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn serve_media(
|
||||
req: HttpRequest,
|
||||
requester: web::types::State<Requester>,
|
||||
) -> Result<impl web::Responder, web::Error> {
|
||||
let endpoint = req.match_info().query("endpoint").to_string();
|
||||
let proxy = NoodlemagazineProxy::new();
|
||||
let Some((video_page_url, source_url)) =
|
||||
proxy.resolve_source_url(endpoint, requester.clone()).await
|
||||
else {
|
||||
return Ok(web::HttpResponse::BadGateway().finish());
|
||||
};
|
||||
|
||||
if !NoodlemagazineProxy::is_hls_url(&source_url) {
|
||||
return Ok(web::HttpResponse::Found()
|
||||
.header("Location", source_url)
|
||||
.finish());
|
||||
}
|
||||
|
||||
let mut upstream_requester = requester.get_ref().clone();
|
||||
let upstream = match upstream_requester
|
||||
.get_raw_with_headers(&source_url, vec![("Referer".to_string(), video_page_url)])
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(_) => return Ok(web::HttpResponse::BadGateway().finish()),
|
||||
};
|
||||
|
||||
let manifest_body = upstream.text().await.map_err(error::ErrorBadGateway)?;
|
||||
let rewritten_manifest =
|
||||
match NoodlemagazineProxy::rewrite_manifest(&source_url, &manifest_body) {
|
||||
Some(body) => body,
|
||||
None => return Ok(web::HttpResponse::BadGateway().finish()),
|
||||
};
|
||||
|
||||
Ok(web::HttpResponse::Ok()
|
||||
.header(CONTENT_TYPE, "application/vnd.apple.mpegurl")
|
||||
.body(rewritten_manifest))
|
||||
}
|
||||
|
||||
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 = NoodlemagazineProxy::normalize_image_url(&endpoint);
|
||||
|
||||
if !NoodlemagazineProxy::is_allowed_thumb_url(&image_url) {
|
||||
return Ok(web::HttpResponse::BadRequest().finish());
|
||||
}
|
||||
|
||||
let mut requester = requester.get_ref().clone();
|
||||
let _ = requester
|
||||
.get_with_headers(
|
||||
NoodlemagazineProxy::root_referer(),
|
||||
NoodlemagazineProxy::root_html_headers(),
|
||||
Some(Version::HTTP_11),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut headers = NoodlemagazineProxy::image_headers(&requester, image_url.as_str());
|
||||
let mut upstream = requester
|
||||
.get_raw_with_headers(image_url.as_str(), headers.clone())
|
||||
.await
|
||||
.ok();
|
||||
|
||||
let needs_warmup = upstream
|
||||
.as_ref()
|
||||
.map(|response| !response.status().is_success())
|
||||
.unwrap_or(true);
|
||||
|
||||
if needs_warmup {
|
||||
let _ = requester
|
||||
.get_with_headers(image_url.as_str(), headers.clone(), Some(Version::HTTP_11))
|
||||
.await;
|
||||
headers = NoodlemagazineProxy::image_headers(&requester, image_url.as_str());
|
||||
upstream = requester
|
||||
.get_raw_with_headers(image_url.as_str(), headers)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
|
||||
let Some(upstream) = upstream.filter(|response| response.status().is_success()) else {
|
||||
return Ok(web::HttpResponse::NotFound().finish());
|
||||
};
|
||||
|
||||
let status = upstream.status();
|
||||
let headers = upstream.headers().clone();
|
||||
let content_type = headers
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_default();
|
||||
|
||||
if !NoodlemagazineProxy::is_binary_image_content_type(&content_type) {
|
||||
return Ok(web::HttpResponse::BadGateway().finish());
|
||||
}
|
||||
|
||||
let bytes = upstream.bytes().await.map_err(error::ErrorBadGateway)?;
|
||||
let mut resp = Response::build(status);
|
||||
if !content_type.is_empty() {
|
||||
resp.set_header(CONTENT_TYPE, content_type);
|
||||
}
|
||||
if let Some(cl) = headers.get(CONTENT_LENGTH) {
|
||||
if let Ok(cl_str) = cl.to_str() {
|
||||
resp.set_header(CONTENT_LENGTH, cl_str);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(resp.body(bytes.to_vec()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::NoodlemagazineProxy;
|
||||
|
||||
#[test]
|
||||
fn extracts_playlist_from_page() {
|
||||
let html = r#"
|
||||
<script>
|
||||
window.playlist = {"sources":[{"file":"https://cdn.example/360.mp4","label":"360p"}]};
|
||||
</script>
|
||||
"#;
|
||||
|
||||
assert_eq!(
|
||||
NoodlemagazineProxy::extract_playlist(html),
|
||||
Some(r#"{"sources":[{"file":"https://cdn.example/360.mp4","label":"360p"}]}"#)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_hls_then_highest_quality() {
|
||||
let playlist = r#"{
|
||||
"sources": [
|
||||
{"file":"https://cdn.example/360.mp4","label":"360p"},
|
||||
{"file":"https://cdn.example/720.mp4","label":"720p"},
|
||||
{"file":"https://cdn.example/master.m3u8","label":"1080p"}
|
||||
]
|
||||
}"#;
|
||||
|
||||
assert_eq!(
|
||||
NoodlemagazineProxy::select_best_source(playlist).as_deref(),
|
||||
Some("https://cdn.example/master.m3u8")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrites_manifest_to_direct_absolute_urls() {
|
||||
let manifest = "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1\nlow/index.m3u8\n#EXT-X-KEY:METHOD=AES-128,URI=\"keys/key.bin\"\nsegment0.ts";
|
||||
|
||||
let rewritten =
|
||||
NoodlemagazineProxy::rewrite_manifest("https://cdn.example/hls/master.m3u8", manifest)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
rewritten,
|
||||
"#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1\nhttps://cdn.example/hls/low/index.m3u8\n#EXT-X-KEY:METHOD=AES-128,URI=\"https://cdn.example/hls/keys/key.bin\"\nhttps://cdn.example/hls/segment0.ts"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_https_image_thumbs_but_rejects_local_or_non_images() {
|
||||
assert!(NoodlemagazineProxy::is_allowed_thumb_url(
|
||||
"https://noodlemagazine.com/thumbs/example.webp"
|
||||
));
|
||||
assert!(NoodlemagazineProxy::is_allowed_thumb_url(
|
||||
"https://cdn.example/previews/example.jpg"
|
||||
));
|
||||
assert!(!NoodlemagazineProxy::is_allowed_thumb_url(
|
||||
"https://noodlemagazine.com/watch/-123_456"
|
||||
));
|
||||
assert!(!NoodlemagazineProxy::is_allowed_thumb_url(
|
||||
"https://localhost/thumb.jpg"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognizes_binary_image_content_types() {
|
||||
assert!(NoodlemagazineProxy::is_binary_image_content_type(
|
||||
"image/webp"
|
||||
));
|
||||
assert!(NoodlemagazineProxy::is_binary_image_content_type(
|
||||
"image/jpeg; charset=binary"
|
||||
));
|
||||
assert!(!NoodlemagazineProxy::is_binary_image_content_type(
|
||||
"text/html; charset=utf-8"
|
||||
));
|
||||
assert!(!NoodlemagazineProxy::is_binary_image_content_type(
|
||||
"application/json"
|
||||
));
|
||||
}
|
||||
}
|
||||
252
src/proxies/pimpbunny.rs
Normal file
@@ -0,0 +1,252 @@
|
||||
use ntex::web;
|
||||
use regex::Regex;
|
||||
use serde_json::Value;
|
||||
use url::Url;
|
||||
use wreq::Version;
|
||||
|
||||
use crate::util::requester::Requester;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PimpbunnyProxy {}
|
||||
|
||||
impl PimpbunnyProxy {
|
||||
const FIREFOX_USER_AGENT: &'static str =
|
||||
"Mozilla/5.0 (X11; Linux x86_64; rv:147.0) Gecko/20100101 Firefox/147.0";
|
||||
const HTML_ACCEPT: &'static str =
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8";
|
||||
|
||||
pub fn new() -> Self {
|
||||
PimpbunnyProxy {}
|
||||
}
|
||||
|
||||
fn normalize_detail_url(url: &str) -> Option<String> {
|
||||
let normalized = if url.starts_with("http://") || url.starts_with("https://") {
|
||||
url.to_string()
|
||||
} else {
|
||||
format!("https://{}", url.trim_start_matches('/'))
|
||||
};
|
||||
|
||||
Self::is_allowed_detail_url(&normalized).then_some(normalized)
|
||||
}
|
||||
|
||||
fn is_allowed_detail_url(url: &str) -> bool {
|
||||
let Some(url) = Url::parse(url).ok() else {
|
||||
return false;
|
||||
};
|
||||
if url.scheme() != "https" {
|
||||
return false;
|
||||
}
|
||||
let Some(host) = url.host_str() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
matches!(host, "pimpbunny.com" | "www.pimpbunny.com")
|
||||
&& !url.path().starts_with("/contents/videos_screenshots/")
|
||||
}
|
||||
|
||||
fn root_referer() -> &'static str {
|
||||
"https://pimpbunny.com/"
|
||||
}
|
||||
|
||||
fn html_headers_with_referer(referer: &str) -> Vec<(String, String)> {
|
||||
vec![
|
||||
("Referer".to_string(), referer.to_string()),
|
||||
(
|
||||
"User-Agent".to_string(),
|
||||
Self::FIREFOX_USER_AGENT.to_string(),
|
||||
),
|
||||
("Accept".to_string(), Self::HTML_ACCEPT.to_string()),
|
||||
("Accept-Language".to_string(), "en-US,en;q=0.9".to_string()),
|
||||
]
|
||||
}
|
||||
|
||||
fn headers_with_cookies(
|
||||
requester: &Requester,
|
||||
request_url: &str,
|
||||
referer: &str,
|
||||
) -> Vec<(String, String)> {
|
||||
let mut headers = Self::html_headers_with_referer(referer);
|
||||
if let Some(cookie) = requester.cookie_header_for_url(request_url) {
|
||||
headers.push(("Cookie".to_string(), cookie));
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
async fn warm_root_session(requester: &mut Requester) {
|
||||
let _ = requester
|
||||
.get_with_headers(
|
||||
Self::root_referer(),
|
||||
Self::html_headers_with_referer(Self::root_referer()),
|
||||
Some(Version::HTTP_11),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn extract_json_ld_video(text: &str) -> Option<Value> {
|
||||
let script_regex =
|
||||
Regex::new(r#"(?s)<script[^>]+application/ld\+json[^>]*>(.*?)</script>"#).ok()?;
|
||||
|
||||
for captures in script_regex.captures_iter(text) {
|
||||
let raw = captures.get(1).map(|value| value.as_str().trim())?;
|
||||
let parsed: Value = serde_json::from_str(raw).ok()?;
|
||||
|
||||
if let Some(video) = Self::find_video_object(&parsed) {
|
||||
return Some(video);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn find_video_object(parsed: &Value) -> Option<Value> {
|
||||
if parsed
|
||||
.get("@type")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value == "VideoObject")
|
||||
{
|
||||
return Some(parsed.clone());
|
||||
}
|
||||
|
||||
if parsed
|
||||
.get("contentUrl")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
{
|
||||
return Some(parsed.clone());
|
||||
}
|
||||
|
||||
if let Some(graph) = parsed.get("@graph").and_then(Value::as_array) {
|
||||
for item in graph {
|
||||
if item
|
||||
.get("@type")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value == "VideoObject")
|
||||
{
|
||||
return Some(item.clone());
|
||||
}
|
||||
if item
|
||||
.get("contentUrl")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
{
|
||||
return Some(item.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(array) = parsed.as_array() {
|
||||
for item in array {
|
||||
if let Some(video) = Self::find_video_object(item) {
|
||||
return Some(video);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_stream_url(json_ld: &Value) -> Option<String> {
|
||||
json_ld
|
||||
.get("contentUrl")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn extract_stream_url_from_html(text: &str) -> Option<String> {
|
||||
Regex::new(r#""contentUrl"\s*:\s*"([^"]+)""#)
|
||||
.ok()?
|
||||
.captures(text)
|
||||
.and_then(|captures| captures.get(1))
|
||||
.map(|value| value.as_str().trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::proxies::Proxy for PimpbunnyProxy {
|
||||
async fn get_video_url(&self, url: String, requester: web::types::State<Requester>) -> String {
|
||||
let Some(detail_url) = Self::normalize_detail_url(&url) else {
|
||||
return String::new();
|
||||
};
|
||||
|
||||
let mut requester = requester.get_ref().clone();
|
||||
Self::warm_root_session(&mut requester).await;
|
||||
let headers = Self::headers_with_cookies(&requester, &detail_url, Self::root_referer());
|
||||
let text = match requester
|
||||
.get_with_headers(&detail_url, headers, Some(Version::HTTP_2))
|
||||
.await
|
||||
{
|
||||
Ok(text) => text,
|
||||
Err(_) => return String::new(),
|
||||
};
|
||||
|
||||
Self::extract_json_ld_video(&text)
|
||||
.and_then(|json_ld| Self::extract_stream_url(&json_ld))
|
||||
.or_else(|| Self::extract_stream_url_from_html(&text))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::PimpbunnyProxy;
|
||||
|
||||
#[test]
|
||||
fn allows_only_pimpbunny_detail_urls() {
|
||||
assert!(PimpbunnyProxy::is_allowed_detail_url(
|
||||
"https://pimpbunny.com/videos/example-video/"
|
||||
));
|
||||
assert!(PimpbunnyProxy::is_allowed_detail_url(
|
||||
"https://www.pimpbunny.com/video/example/"
|
||||
));
|
||||
assert!(!PimpbunnyProxy::is_allowed_detail_url(
|
||||
"http://pimpbunny.com/videos/example-video/"
|
||||
));
|
||||
assert!(!PimpbunnyProxy::is_allowed_detail_url(
|
||||
"https://pimpbunny.com/contents/videos_screenshots/1/2/3.jpg"
|
||||
));
|
||||
assert!(!PimpbunnyProxy::is_allowed_detail_url(
|
||||
"https://example.com/videos/example-video/"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_content_url_from_json_ld() {
|
||||
let html = r#"
|
||||
<script type="application/ld+json">{"contentUrl":"https://cdn.example/video.mp4"}</script>
|
||||
"#;
|
||||
|
||||
let json_ld = PimpbunnyProxy::extract_json_ld_video(html).expect("json-ld should parse");
|
||||
assert_eq!(
|
||||
PimpbunnyProxy::extract_stream_url(&json_ld).as_deref(),
|
||||
Some("https://cdn.example/video.mp4")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_video_object_from_graph_script() {
|
||||
let html = r#"
|
||||
<script type="application/ld+json">
|
||||
{"@graph":[{"@type":"BreadcrumbList"},{"@type":"VideoObject","contentUrl":"https://cdn.example/graph.mp4"}]}
|
||||
</script>
|
||||
"#;
|
||||
|
||||
let json_ld =
|
||||
PimpbunnyProxy::extract_json_ld_video(html).expect("video object should parse");
|
||||
assert_eq!(
|
||||
PimpbunnyProxy::extract_stream_url(&json_ld).as_deref(),
|
||||
Some("https://cdn.example/graph.mp4")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_raw_content_url_match() {
|
||||
let html = r#"{"contentUrl":"https://cdn.example/fallback.mp4"}"#;
|
||||
|
||||
assert_eq!(
|
||||
PimpbunnyProxy::extract_stream_url_from_html(html).as_deref(),
|
||||
Some("https://cdn.example/fallback.mp4")
|
||||
);
|
||||
}
|
||||
}
|
||||
169
src/proxies/pimpbunnythumb.rs
Normal file
@@ -0,0 +1,169 @@
|
||||
use ntex::http::header::{CONTENT_LENGTH, CONTENT_TYPE};
|
||||
use ntex::{
|
||||
http::Response,
|
||||
web::{self, HttpRequest, error},
|
||||
};
|
||||
use url::Url;
|
||||
use wreq::Version;
|
||||
|
||||
use crate::util::requester::Requester;
|
||||
|
||||
const FIREFOX_USER_AGENT: &str =
|
||||
"Mozilla/5.0 (X11; Linux x86_64; rv:147.0) Gecko/20100101 Firefox/147.0";
|
||||
const HTML_ACCEPT: &str =
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8";
|
||||
const IMAGE_ACCEPT: &str = "image/avif,image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5";
|
||||
|
||||
fn root_referer() -> &'static str {
|
||||
"https://pimpbunny.com/"
|
||||
}
|
||||
|
||||
fn root_html_headers() -> Vec<(String, String)> {
|
||||
vec![
|
||||
("Referer".to_string(), root_referer().to_string()),
|
||||
("User-Agent".to_string(), FIREFOX_USER_AGENT.to_string()),
|
||||
("Accept".to_string(), HTML_ACCEPT.to_string()),
|
||||
("Accept-Language".to_string(), "en-US,en;q=0.9".to_string()),
|
||||
]
|
||||
}
|
||||
|
||||
fn image_headers(requester: &Requester, image_url: &str) -> Vec<(String, String)> {
|
||||
let mut headers = vec![
|
||||
("Referer".to_string(), root_referer().to_string()),
|
||||
("User-Agent".to_string(), FIREFOX_USER_AGENT.to_string()),
|
||||
("Accept".to_string(), IMAGE_ACCEPT.to_string()),
|
||||
("Accept-Language".to_string(), "en-US,en;q=0.9".to_string()),
|
||||
];
|
||||
if let Some(cookie) = requester.cookie_header_for_url(image_url) {
|
||||
headers.push(("Cookie".to_string(), cookie));
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
fn is_allowed_thumb_url(url: &str) -> bool {
|
||||
let Some(url) = Url::parse(url).ok() else {
|
||||
return false;
|
||||
};
|
||||
if url.scheme() != "https" {
|
||||
return false;
|
||||
}
|
||||
let Some(host) = url.host_str() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
matches!(host, "pimpbunny.com" | "www.pimpbunny.com")
|
||||
&& url.path().starts_with("/contents/videos_screenshots/")
|
||||
}
|
||||
|
||||
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 = if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
|
||||
endpoint
|
||||
} else {
|
||||
format!("https://{}", endpoint.trim_start_matches('/'))
|
||||
};
|
||||
|
||||
if !is_allowed_thumb_url(&image_url) {
|
||||
return Ok(web::HttpResponse::BadRequest().finish());
|
||||
}
|
||||
|
||||
let mut requester = requester.get_ref().clone();
|
||||
let _ = requester
|
||||
.get_with_headers(root_referer(), root_html_headers(), Some(Version::HTTP_11))
|
||||
.await;
|
||||
|
||||
let mut headers = image_headers(&requester, image_url.as_str());
|
||||
|
||||
let mut upstream = requester
|
||||
.get_raw_with_headers(image_url.as_str(), headers.clone())
|
||||
.await
|
||||
.ok();
|
||||
|
||||
let needs_warmup = upstream
|
||||
.as_ref()
|
||||
.map(|response| !response.status().is_success())
|
||||
.unwrap_or(true);
|
||||
|
||||
if needs_warmup {
|
||||
let _ = requester
|
||||
.get_with_headers(root_referer(), root_html_headers(), Some(Version::HTTP_11))
|
||||
.await;
|
||||
headers = image_headers(&requester, image_url.as_str());
|
||||
|
||||
upstream = requester
|
||||
.get_raw_with_headers(image_url.as_str(), headers.clone())
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
|
||||
let needs_image_specific_warmup = upstream
|
||||
.as_ref()
|
||||
.map(|response| !response.status().is_success())
|
||||
.unwrap_or(true);
|
||||
|
||||
if needs_image_specific_warmup {
|
||||
let _ = requester
|
||||
.get_with_headers(image_url.as_str(), headers.clone(), Some(Version::HTTP_11))
|
||||
.await;
|
||||
headers = image_headers(&requester, image_url.as_str());
|
||||
|
||||
upstream = requester
|
||||
.get_raw_with_headers(image_url.as_str(), headers)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
|
||||
let Some(upstream) = upstream.filter(|response| response.status().is_success()) else {
|
||||
return Ok(web::HttpResponse::NotFound().finish());
|
||||
};
|
||||
|
||||
let status = upstream.status();
|
||||
let headers = upstream.headers().clone();
|
||||
let bytes = upstream.bytes().await.map_err(error::ErrorBadGateway)?;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(resp.body(bytes.to_vec()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_allowed_thumb_url;
|
||||
|
||||
#[test]
|
||||
fn allows_expected_pimpbunny_thumb_paths() {
|
||||
assert!(is_allowed_thumb_url(
|
||||
"https://pimpbunny.com/contents/videos_screenshots/517000/517329/800x450/1.jpg"
|
||||
));
|
||||
assert!(is_allowed_thumb_url(
|
||||
"https://www.pimpbunny.com/contents/videos_screenshots/1/2/800x450/3.webp"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_thumb_or_non_pimpbunny_urls() {
|
||||
assert!(!is_allowed_thumb_url(
|
||||
"http://pimpbunny.com/contents/videos_screenshots/x.jpg"
|
||||
));
|
||||
assert!(!is_allowed_thumb_url(
|
||||
"https://pimpbunny.com/videos/example-video/"
|
||||
));
|
||||
assert!(!is_allowed_thumb_url(
|
||||
"https://example.com/contents/videos_screenshots/x.jpg"
|
||||
));
|
||||
}
|
||||
}
|
||||
369
src/proxies/porndish.rs
Normal file
@@ -0,0 +1,369 @@
|
||||
use ntex::web;
|
||||
use regex::Regex;
|
||||
use std::process::Command;
|
||||
use url::Url;
|
||||
|
||||
use crate::util::requester::Requester;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PorndishProxy {}
|
||||
|
||||
impl PorndishProxy {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
|
||||
fn normalize_detail_url(endpoint: &str) -> Option<String> {
|
||||
let endpoint = endpoint.trim();
|
||||
if endpoint.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
|
||||
Some(endpoint.to_string())
|
||||
} else {
|
||||
Some(format!("https://{}", endpoint.trim_start_matches('/')))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_url(url: &str) -> Option<Url> {
|
||||
Url::parse(url).ok()
|
||||
}
|
||||
|
||||
fn is_porndish_host(host: &str) -> bool {
|
||||
matches!(host, "www.porndish.com" | "porndish.com")
|
||||
}
|
||||
|
||||
fn is_myvidplay_host(host: &str) -> bool {
|
||||
matches!(host, "myvidplay.com" | "www.myvidplay.com")
|
||||
}
|
||||
|
||||
fn is_vidara_host(host: &str) -> bool {
|
||||
matches!(host, "vidara.so" | "www.vidara.so")
|
||||
}
|
||||
|
||||
fn is_allowed_detail_url(url: &str) -> bool {
|
||||
let Some(url) = Self::parse_url(url) else {
|
||||
return false;
|
||||
};
|
||||
if url.scheme() != "https" {
|
||||
return false;
|
||||
}
|
||||
let Some(host) = url.host_str() else {
|
||||
return false;
|
||||
};
|
||||
Self::is_porndish_host(host) && url.path().starts_with("/porn/")
|
||||
}
|
||||
|
||||
fn is_allowed_myvidplay_iframe_url(url: &str) -> bool {
|
||||
let Some(url) = Self::parse_url(url) else {
|
||||
return false;
|
||||
};
|
||||
if url.scheme() != "https" {
|
||||
return false;
|
||||
}
|
||||
let Some(host) = url.host_str() else {
|
||||
return false;
|
||||
};
|
||||
Self::is_myvidplay_host(host) && url.path().starts_with("/e/")
|
||||
}
|
||||
|
||||
fn is_allowed_myvidplay_pass_url(url: &str) -> bool {
|
||||
let Some(url) = Self::parse_url(url) else {
|
||||
return false;
|
||||
};
|
||||
if url.scheme() != "https" {
|
||||
return false;
|
||||
}
|
||||
let Some(host) = url.host_str() else {
|
||||
return false;
|
||||
};
|
||||
Self::is_myvidplay_host(host) && url.path().starts_with("/pass_md5/")
|
||||
}
|
||||
|
||||
fn is_allowed_vidara_iframe_url(url: &str) -> bool {
|
||||
let Some(url) = Self::parse_url(url) else {
|
||||
return false;
|
||||
};
|
||||
if url.scheme() != "https" {
|
||||
return false;
|
||||
}
|
||||
let Some(host) = url.host_str() else {
|
||||
return false;
|
||||
};
|
||||
Self::is_vidara_host(host) && url.path().starts_with("/e/")
|
||||
}
|
||||
|
||||
fn vidara_api_url(iframe_url: &str) -> Option<String> {
|
||||
let url = Self::parse_url(iframe_url)?;
|
||||
if !Self::is_allowed_vidara_iframe_url(iframe_url) {
|
||||
return None;
|
||||
}
|
||||
let filecode = url
|
||||
.path_segments()?
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.next_back()?
|
||||
.to_string();
|
||||
if filecode.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(format!("https://vidara.so/api/stream?filecode={filecode}"))
|
||||
}
|
||||
|
||||
fn regex(value: &str) -> Option<Regex> {
|
||||
Regex::new(value).ok()
|
||||
}
|
||||
|
||||
async fn fetch_with_curl_cffi(url: &str, referer: Option<&str>) -> Option<String> {
|
||||
let url = url.to_string();
|
||||
let referer = referer.unwrap_or("").to_string();
|
||||
|
||||
let output = tokio::task::spawn_blocking(move || {
|
||||
Command::new("python3")
|
||||
.arg("-c")
|
||||
.arg(
|
||||
r#"
|
||||
import sys
|
||||
from curl_cffi import requests
|
||||
|
||||
url = sys.argv[1]
|
||||
referer = sys.argv[2] if len(sys.argv) > 2 else ""
|
||||
headers = {}
|
||||
if referer:
|
||||
headers["Referer"] = referer
|
||||
|
||||
response = requests.get(
|
||||
url,
|
||||
impersonate="chrome",
|
||||
timeout=30,
|
||||
allow_redirects=True,
|
||||
headers=headers,
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
sys.exit(1)
|
||||
sys.stdout.buffer.write(response.content)
|
||||
"#,
|
||||
)
|
||||
.arg(url)
|
||||
.arg(referer)
|
||||
.output()
|
||||
})
|
||||
.await
|
||||
.ok()?
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
|
||||
async fn resolve_first_redirect(url: &str) -> Option<String> {
|
||||
let url = url.to_string();
|
||||
let output = tokio::task::spawn_blocking(move || {
|
||||
Command::new("python3")
|
||||
.arg("-c")
|
||||
.arg(
|
||||
r#"
|
||||
import sys
|
||||
from curl_cffi import requests
|
||||
|
||||
url = sys.argv[1]
|
||||
response = requests.get(
|
||||
url,
|
||||
impersonate="chrome",
|
||||
timeout=30,
|
||||
allow_redirects=False,
|
||||
)
|
||||
location = response.headers.get("location", "")
|
||||
if location:
|
||||
sys.stdout.write(location)
|
||||
"#,
|
||||
)
|
||||
.arg(url)
|
||||
.output()
|
||||
})
|
||||
.await
|
||||
.ok()?
|
||||
.ok()?;
|
||||
|
||||
let location = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if location.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(location)
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_iframe_fragments(html: &str) -> Vec<String> {
|
||||
let Some(regex) = Self::regex(r#"const\s+[A-Za-z0-9_]+Content\s*=\s*"((?:\\.|[^"\\])*)";"#)
|
||||
else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let mut fragments = Vec::new();
|
||||
for captures in regex.captures_iter(html) {
|
||||
let Some(value) = captures.get(1).map(|value| value.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let encoded = format!("\"{value}\"");
|
||||
let decoded = serde_json::from_str::<String>(&encoded).unwrap_or_default();
|
||||
if decoded.contains("<iframe") {
|
||||
fragments.push(decoded);
|
||||
}
|
||||
}
|
||||
fragments
|
||||
}
|
||||
|
||||
fn parse_embed_source(fragment: &str) -> Option<String> {
|
||||
let regex = Self::regex(r#"(?is)<iframe[^>]+src="([^"]+)"[^>]*>"#)?;
|
||||
regex
|
||||
.captures(fragment)
|
||||
.and_then(|captures| captures.get(1))
|
||||
.map(|value| value.as_str().to_string())
|
||||
}
|
||||
|
||||
async fn resolve_myvidplay_stream(iframe_url: &str) -> Option<String> {
|
||||
if !Self::is_allowed_myvidplay_iframe_url(iframe_url) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let html =
|
||||
Self::fetch_with_curl_cffi(iframe_url, Some("https://www.porndish.com/")).await?;
|
||||
let pass_regex = Self::regex(r#"\$\.get\(\s*['"](/pass_md5/[^'"]+)['"]"#)?;
|
||||
let path = pass_regex
|
||||
.captures(&html)
|
||||
.and_then(|captures| captures.get(1))
|
||||
.map(|value| value.as_str().to_string())?;
|
||||
|
||||
let token = path.trim_end_matches('/').rsplit('/').next()?.to_string();
|
||||
if token.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let pass_url = if path.starts_with("http://") || path.starts_with("https://") {
|
||||
path
|
||||
} else {
|
||||
let base = Url::parse(iframe_url).ok()?;
|
||||
base.join(&path).ok()?.to_string()
|
||||
};
|
||||
if !Self::is_allowed_myvidplay_pass_url(&pass_url) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let base = Self::fetch_with_curl_cffi(&pass_url, Some(iframe_url))
|
||||
.await?
|
||||
.trim()
|
||||
.to_string();
|
||||
if base.is_empty() || base == "RELOAD" || !base.starts_with("http") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let chars = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()?
|
||||
.as_millis();
|
||||
let suffix = (0..10)
|
||||
.map(|index| {
|
||||
let pos = ((now + (index as u128 * 17)) % chars.len() as u128) as usize;
|
||||
chars[pos] as char
|
||||
})
|
||||
.collect::<String>();
|
||||
|
||||
let stream_url = format!("{base}{suffix}?token={token}&expiry={now}");
|
||||
Some(
|
||||
Self::resolve_first_redirect(&stream_url)
|
||||
.await
|
||||
.unwrap_or(stream_url),
|
||||
)
|
||||
}
|
||||
|
||||
async fn resolve_vidara_stream(iframe_url: &str) -> Option<String> {
|
||||
let api_url = Self::vidara_api_url(iframe_url)?;
|
||||
let response = Self::fetch_with_curl_cffi(&api_url, Some(iframe_url)).await?;
|
||||
let json: serde_json::Value = serde_json::from_str(&response).ok()?;
|
||||
let stream_url = json
|
||||
.get("streaming_url")
|
||||
.and_then(|value| value.as_str())?
|
||||
.trim()
|
||||
.to_string();
|
||||
if stream_url.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(stream_url)
|
||||
}
|
||||
|
||||
pub async fn get_video_url(
|
||||
&self,
|
||||
url: String,
|
||||
_requester: web::types::State<Requester>,
|
||||
) -> String {
|
||||
let Some(detail_url) = Self::normalize_detail_url(&url) else {
|
||||
return String::new();
|
||||
};
|
||||
if !Self::is_allowed_detail_url(&detail_url) {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let Some(html) =
|
||||
Self::fetch_with_curl_cffi(&detail_url, Some("https://www.porndish.com/")).await
|
||||
else {
|
||||
return String::new();
|
||||
};
|
||||
|
||||
let mut fallback_iframe: Option<String> = None;
|
||||
|
||||
for fragment in Self::extract_iframe_fragments(&html) {
|
||||
let Some(iframe_url) = Self::parse_embed_source(&fragment) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let iframe_url =
|
||||
if iframe_url.starts_with("http://") || iframe_url.starts_with("https://") {
|
||||
iframe_url
|
||||
} else if iframe_url.starts_with("//") {
|
||||
format!("https:{iframe_url}")
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if Self::is_allowed_vidara_iframe_url(&iframe_url) {
|
||||
if let Some(stream_url) = Self::resolve_vidara_stream(&iframe_url).await {
|
||||
return stream_url;
|
||||
}
|
||||
}
|
||||
|
||||
if fallback_iframe.is_none() && Self::is_allowed_myvidplay_iframe_url(&iframe_url) {
|
||||
fallback_iframe = Some(iframe_url);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(iframe_url) = fallback_iframe {
|
||||
if let Some(stream_url) = Self::resolve_myvidplay_stream(&iframe_url).await {
|
||||
return stream_url;
|
||||
}
|
||||
}
|
||||
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::PorndishProxy;
|
||||
|
||||
#[test]
|
||||
fn allows_only_porndish_detail_urls() {
|
||||
assert!(PorndishProxy::is_allowed_detail_url(
|
||||
"https://www.porndish.com/porn/example/"
|
||||
));
|
||||
assert!(!PorndishProxy::is_allowed_detail_url(
|
||||
"https://www.porndish.com/search/example/"
|
||||
));
|
||||
assert!(!PorndishProxy::is_allowed_detail_url(
|
||||
"https://example.com/porn/example/"
|
||||
));
|
||||
}
|
||||
}
|
||||
80
src/proxies/porndishthumb.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use ntex::http::header::CONTENT_TYPE;
|
||||
use ntex::{
|
||||
http::Response,
|
||||
web::{self, HttpRequest, error},
|
||||
};
|
||||
use std::process::Command;
|
||||
use url::Url;
|
||||
|
||||
use crate::util::requester::Requester;
|
||||
|
||||
fn is_allowed_thumb_url(url: &str) -> bool {
|
||||
let Some(url) = Url::parse(url).ok() else {
|
||||
return false;
|
||||
};
|
||||
if url.scheme() != "https" {
|
||||
return false;
|
||||
}
|
||||
let Some(host) = url.host_str() else {
|
||||
return false;
|
||||
};
|
||||
matches!(host, "www.porndish.com" | "porndish.com")
|
||||
&& url.path().starts_with("/wp-content/uploads/")
|
||||
}
|
||||
|
||||
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 = if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
|
||||
endpoint
|
||||
} else {
|
||||
format!("https://{}", endpoint.trim_start_matches('/'))
|
||||
};
|
||||
if !is_allowed_thumb_url(&image_url) {
|
||||
return Ok(web::HttpResponse::BadRequest().finish());
|
||||
}
|
||||
|
||||
let output = tokio::task::spawn_blocking(move || {
|
||||
Command::new("python3")
|
||||
.arg("-c")
|
||||
.arg(
|
||||
r#"
|
||||
import sys
|
||||
from curl_cffi import requests
|
||||
|
||||
url = sys.argv[1]
|
||||
response = requests.get(
|
||||
url,
|
||||
impersonate="chrome",
|
||||
timeout=30,
|
||||
allow_redirects=True,
|
||||
headers={"Referer": "https://www.porndish.com/"},
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
sys.stderr.write(f"status={response.status_code}\n")
|
||||
sys.exit(1)
|
||||
sys.stderr.write(response.headers.get("content-type", "application/octet-stream"))
|
||||
sys.stdout.buffer.write(response.content)
|
||||
"#,
|
||||
)
|
||||
.arg(image_url)
|
||||
.output()
|
||||
})
|
||||
.await
|
||||
.map_err(error::ErrorBadGateway)?
|
||||
.map_err(error::ErrorBadGateway)?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Ok(web::HttpResponse::NotFound().finish());
|
||||
}
|
||||
|
||||
let content_type = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let mut resp = Response::build(ntex::http::StatusCode::OK);
|
||||
if !content_type.is_empty() {
|
||||
resp.set_header(CONTENT_TYPE, content_type);
|
||||
}
|
||||
|
||||
Ok(resp.body(output.stdout))
|
||||
}
|
||||
243
src/proxies/pornhd3x.rs
Normal file
@@ -0,0 +1,243 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use ntex::web;
|
||||
use regex::Regex;
|
||||
use serde_json::Value;
|
||||
use url::Url;
|
||||
use wreq::Version;
|
||||
|
||||
use crate::util::requester::Requester;
|
||||
|
||||
const BASE_URL: &str = "https://www.pornhd3x.tv";
|
||||
const SOURCE_SECRET: &str = "98126avrbi6m49vd7shxkn985";
|
||||
const SOURCE_COOKIE_PREFIX: &str = "826avrbi6m49vd7shxkn985m";
|
||||
const SOURCE_COOKIE_SUFFIX: &str = "k06twz87wwxtp3dqiicks2df";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Pornhd3xProxy {
|
||||
source_counter: Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
impl Pornhd3xProxy {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
source_counter: Arc::new(AtomicU32::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_detail_request(endpoint: &str) -> Option<(String, Option<String>)> {
|
||||
let endpoint = endpoint.trim().trim_start_matches('/');
|
||||
if endpoint.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (detail_part, quality) = match endpoint.split_once("/__quality__/") {
|
||||
Some((detail, quality)) => {
|
||||
(detail, Some(quality.replace("%20", " ").trim().to_string()))
|
||||
}
|
||||
None => (endpoint, None),
|
||||
};
|
||||
|
||||
let detail_url =
|
||||
if detail_part.starts_with("http://") || detail_part.starts_with("https://") {
|
||||
detail_part.to_string()
|
||||
} else {
|
||||
format!("https://{}", detail_part.trim_start_matches('/'))
|
||||
};
|
||||
|
||||
Self::is_allowed_detail_url(&detail_url)
|
||||
.then_some((detail_url, quality.filter(|value| !value.is_empty())))
|
||||
}
|
||||
|
||||
fn is_allowed_detail_url(url: &str) -> bool {
|
||||
let Some(url) = Url::parse(url).ok() else {
|
||||
return false;
|
||||
};
|
||||
if url.scheme() != "https" {
|
||||
return false;
|
||||
}
|
||||
let Some(host) = url.host_str() else {
|
||||
return false;
|
||||
};
|
||||
(host == "www.pornhd3x.tv" || host == "pornhd3x.tv") && url.path().starts_with("/movies/")
|
||||
}
|
||||
|
||||
fn normalize_url(raw: &str) -> String {
|
||||
let value = raw.trim();
|
||||
if value.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
if value.starts_with("//") {
|
||||
return format!("https:{value}");
|
||||
}
|
||||
if value.starts_with('/') {
|
||||
return format!("{BASE_URL}{value}");
|
||||
}
|
||||
if value.starts_with("http://") {
|
||||
return value.replacen("http://", "https://", 1);
|
||||
}
|
||||
value.to_string()
|
||||
}
|
||||
|
||||
fn extract_episode_id(html: &str) -> Option<String> {
|
||||
Regex::new(r#"(?is)(?:id=["']uuid["'][^>]*value=["']|episode-id=["'])([A-Za-z0-9]+)"#)
|
||||
.ok()?
|
||||
.captures(html)
|
||||
.and_then(|captures| captures.get(1))
|
||||
.map(|value| value.as_str().to_string())
|
||||
}
|
||||
|
||||
fn build_source_cookie_name(episode_id: &str) -> String {
|
||||
format!("{SOURCE_COOKIE_PREFIX}{episode_id}{SOURCE_COOKIE_SUFFIX}")
|
||||
}
|
||||
|
||||
fn build_source_hash(episode_id: &str, nonce: &str) -> String {
|
||||
format!(
|
||||
"{:x}",
|
||||
md5::compute(format!("{episode_id}{nonce}{SOURCE_SECRET}"))
|
||||
)
|
||||
}
|
||||
|
||||
fn next_source_request(&self) -> (u32, String) {
|
||||
let count = self.source_counter.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
let nonce = format!("{:06x}", count % 0xFF_FFFF);
|
||||
(count, nonce)
|
||||
}
|
||||
|
||||
async fn fetch_sources(
|
||||
&self,
|
||||
requester: &mut Requester,
|
||||
referer: &str,
|
||||
episode_id: &str,
|
||||
) -> Option<Value> {
|
||||
let (count, nonce) = self.next_source_request();
|
||||
let source_url = format!(
|
||||
"{BASE_URL}/ajax/get_sources/{episode_id}/{hash}?count={count}&mobile=true",
|
||||
hash = Self::build_source_hash(episode_id, &nonce),
|
||||
);
|
||||
let existing_cookie = requester.cookie_header_for_url(&source_url);
|
||||
let cookie_value = format!("{}={nonce}", Self::build_source_cookie_name(episode_id));
|
||||
let combined_cookie = match existing_cookie {
|
||||
Some(existing) if !existing.trim().is_empty() => format!("{existing}; {cookie_value}"),
|
||||
_ => cookie_value,
|
||||
};
|
||||
|
||||
let response = requester
|
||||
.get_with_headers(
|
||||
&source_url,
|
||||
vec![
|
||||
("Cookie".to_string(), combined_cookie),
|
||||
("Referer".to_string(), referer.to_string()),
|
||||
("X-Requested-With".to_string(), "XMLHttpRequest".to_string()),
|
||||
(
|
||||
"Accept".to_string(),
|
||||
"application/json, text/javascript, */*; q=0.01".to_string(),
|
||||
),
|
||||
],
|
||||
Some(Version::HTTP_11),
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
serde_json::from_str::<Value>(&response).ok()
|
||||
}
|
||||
|
||||
fn select_source_url(payload: &Value, quality: Option<&str>) -> Option<String> {
|
||||
let sources = payload
|
||||
.get("playlist")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flat_map(|playlist| {
|
||||
playlist
|
||||
.get("sources")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if let Some(quality) = quality {
|
||||
let quality = quality.trim().to_ascii_lowercase();
|
||||
for source in &sources {
|
||||
let label = source
|
||||
.get("label")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
if label == quality {
|
||||
let file = source.get("file").and_then(Value::as_str)?;
|
||||
return Some(Self::normalize_url(file));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for source in sources {
|
||||
let Some(file) = source.get("file").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let url = Self::normalize_url(file);
|
||||
if !url.is_empty() {
|
||||
return Some(url);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::proxies::Proxy for Pornhd3xProxy {
|
||||
async fn get_video_url(&self, url: String, requester: web::types::State<Requester>) -> String {
|
||||
let Some((detail_url, quality)) = Self::normalize_detail_request(&url) else {
|
||||
return String::new();
|
||||
};
|
||||
|
||||
let mut requester = requester.get_ref().clone();
|
||||
let detail_html = match requester.get(&detail_url, Some(Version::HTTP_11)).await {
|
||||
Ok(text) => text,
|
||||
Err(_) => return String::new(),
|
||||
};
|
||||
let Some(episode_id) = Self::extract_episode_id(&detail_html) else {
|
||||
return String::new();
|
||||
};
|
||||
let Some(payload) = self
|
||||
.fetch_sources(&mut requester, &detail_url, &episode_id)
|
||||
.await
|
||||
else {
|
||||
return String::new();
|
||||
};
|
||||
|
||||
Self::select_source_url(&payload, quality.as_deref()).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Pornhd3xProxy;
|
||||
|
||||
#[test]
|
||||
fn normalizes_detail_endpoint_and_quality() {
|
||||
let (url, quality) = Pornhd3xProxy::normalize_detail_request(
|
||||
"www.pornhd3x.tv/movies/example-video/__quality__/720p",
|
||||
)
|
||||
.expect("proxy target should parse");
|
||||
|
||||
assert_eq!(url, "https://www.pornhd3x.tv/movies/example-video");
|
||||
assert_eq!(quality.as_deref(), Some("720p"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_episode_id_from_detail_markup() {
|
||||
let html = r#"
|
||||
<input id="uuid" value="49Q27JL3HCPVNJQN">
|
||||
<a class="btn-eps" episode-id="OTHER"></a>
|
||||
"#;
|
||||
|
||||
assert_eq!(
|
||||
Pornhd3xProxy::extract_episode_id(html).as_deref(),
|
||||
Some("49Q27JL3HCPVNJQN")
|
||||
);
|
||||
}
|
||||
}
|
||||
297
src/proxies/shooshtime.rs
Normal file
@@ -0,0 +1,297 @@
|
||||
use ntex::http::Response;
|
||||
use ntex::http::header::{CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE};
|
||||
use ntex::web::{self, HttpRequest, error};
|
||||
use regex::Regex;
|
||||
use url::Url;
|
||||
|
||||
use crate::util::requester::Requester;
|
||||
|
||||
const BASE_URL: &str = "https://shooshtime.com";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SourceCandidate {
|
||||
url: String,
|
||||
quality: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShooshtimeProxy {}
|
||||
|
||||
impl ShooshtimeProxy {
|
||||
fn normalize_detail_request(endpoint: &str) -> Option<(String, Option<String>)> {
|
||||
let endpoint = endpoint.trim().trim_start_matches('/');
|
||||
if endpoint.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (detail_part, quality) = match endpoint.split_once("/__quality__/") {
|
||||
Some((detail, quality)) => {
|
||||
(detail, Some(quality.replace("%20", " ").trim().to_string()))
|
||||
}
|
||||
None => (endpoint, None),
|
||||
};
|
||||
|
||||
let mut detail_url =
|
||||
if detail_part.starts_with("http://") || detail_part.starts_with("https://") {
|
||||
detail_part.to_string()
|
||||
} else {
|
||||
format!("https://{}", detail_part.trim_start_matches('/'))
|
||||
};
|
||||
|
||||
if detail_url.contains("/videos/") && !detail_url.ends_with('/') {
|
||||
detail_url.push('/');
|
||||
}
|
||||
|
||||
Self::is_allowed_detail_url(&detail_url)
|
||||
.then_some((detail_url, quality.filter(|value| !value.is_empty())))
|
||||
}
|
||||
|
||||
fn is_allowed_detail_url(url: &str) -> bool {
|
||||
let Some(url) = Url::parse(url).ok() else {
|
||||
return false;
|
||||
};
|
||||
if url.scheme() != "https" {
|
||||
return false;
|
||||
}
|
||||
let Some(host) = url.host_str() else {
|
||||
return false;
|
||||
};
|
||||
(host == "shooshtime.com" || host == "www.shooshtime.com")
|
||||
&& url.path().starts_with("/videos/")
|
||||
}
|
||||
|
||||
fn is_allowed_media_url(url: &str) -> bool {
|
||||
let Some(url) = Url::parse(url).ok() else {
|
||||
return false;
|
||||
};
|
||||
if url.scheme() != "https" {
|
||||
return false;
|
||||
}
|
||||
let Some(host) = url.host_str() else {
|
||||
return false;
|
||||
};
|
||||
(host == "shooshtime.com" || host == "www.shooshtime.com")
|
||||
&& url.path().starts_with("/get_file/")
|
||||
}
|
||||
|
||||
fn normalize_url(raw: &str) -> String {
|
||||
let value = raw.trim().replace("\\/", "/");
|
||||
if value.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
if value.starts_with("//") {
|
||||
return format!("https:{value}");
|
||||
}
|
||||
if value.starts_with('/') {
|
||||
return format!("{BASE_URL}{value}");
|
||||
}
|
||||
if value.starts_with("http://") {
|
||||
return value.replacen("http://", "https://", 1);
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
fn regex(value: &str) -> Option<Regex> {
|
||||
Regex::new(value).ok()
|
||||
}
|
||||
|
||||
fn extract_js_value(block: &str, regex: &Regex) -> Option<String> {
|
||||
regex
|
||||
.captures(block)
|
||||
.and_then(|value| value.get(1))
|
||||
.map(|value| value.as_str().replace("\\/", "/").replace("\\'", "'"))
|
||||
}
|
||||
|
||||
fn extract_sources(html: &str) -> Vec<SourceCandidate> {
|
||||
let Some(flashvars_regex) = Self::regex(r#"(?s)var\s+flashvars\s*=\s*\{(.*?)\};"#) else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(flashvars) = flashvars_regex
|
||||
.captures(html)
|
||||
.and_then(|value| value.get(1))
|
||||
.map(|value| value.as_str().to_string())
|
||||
else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let value_regex = |key: &str| Self::regex(&format!(r#"{key}:\s*'([^']*)'"#));
|
||||
let primary_url_regex = match value_regex("video_url") {
|
||||
Some(value) => value,
|
||||
None => return vec![],
|
||||
};
|
||||
let primary_quality_regex = match value_regex("video_url_text") {
|
||||
Some(value) => value,
|
||||
None => return vec![],
|
||||
};
|
||||
let alt_url_regex = match value_regex("video_alt_url") {
|
||||
Some(value) => value,
|
||||
None => return vec![],
|
||||
};
|
||||
let alt_quality_regex = match value_regex("video_alt_url_text") {
|
||||
Some(value) => value,
|
||||
None => return vec![],
|
||||
};
|
||||
|
||||
let mut sources = Vec::new();
|
||||
|
||||
if let Some(url) = Self::extract_js_value(&flashvars, &primary_url_regex) {
|
||||
let normalized = Self::normalize_url(&url);
|
||||
if !normalized.is_empty() && Self::is_allowed_media_url(&normalized) {
|
||||
sources.push(SourceCandidate {
|
||||
url: normalized,
|
||||
quality: Self::extract_js_value(&flashvars, &primary_quality_regex)
|
||||
.unwrap_or_else(|| "480p".to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(url) = Self::extract_js_value(&flashvars, &alt_url_regex) {
|
||||
let normalized = Self::normalize_url(&url);
|
||||
if !normalized.is_empty() && Self::is_allowed_media_url(&normalized) {
|
||||
sources.push(SourceCandidate {
|
||||
url: normalized,
|
||||
quality: Self::extract_js_value(&flashvars, &alt_quality_regex)
|
||||
.unwrap_or_else(|| "720p".to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
sources
|
||||
}
|
||||
|
||||
fn quality_score(label: &str) -> u32 {
|
||||
label
|
||||
.chars()
|
||||
.filter(|value| value.is_ascii_digit())
|
||||
.collect::<String>()
|
||||
.parse::<u32>()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn select_source_url(html: &str, quality: Option<&str>) -> Option<String> {
|
||||
let sources = Self::extract_sources(html);
|
||||
if sources.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(quality) = quality {
|
||||
let wanted = quality.trim().to_ascii_lowercase();
|
||||
if let Some(source) = sources
|
||||
.iter()
|
||||
.find(|source| source.quality.trim().to_ascii_lowercase() == wanted)
|
||||
{
|
||||
return Some(source.url.clone());
|
||||
}
|
||||
}
|
||||
|
||||
sources
|
||||
.iter()
|
||||
.max_by_key(|source| Self::quality_score(&source.quality))
|
||||
.map(|source| source.url.clone())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn serve_media(
|
||||
req: HttpRequest,
|
||||
requester: web::types::State<Requester>,
|
||||
) -> Result<impl web::Responder, web::Error> {
|
||||
let endpoint = req.match_info().query("endpoint").to_string();
|
||||
let Some((detail_url, quality)) = ShooshtimeProxy::normalize_detail_request(&endpoint) else {
|
||||
return Ok(web::HttpResponse::BadRequest().finish());
|
||||
};
|
||||
|
||||
let mut requester = requester.get_ref().clone();
|
||||
let html = match requester.get(&detail_url, None).await {
|
||||
Ok(html) => html,
|
||||
Err(_) => return Ok(web::HttpResponse::BadGateway().finish()),
|
||||
};
|
||||
|
||||
let Some(source_url) = ShooshtimeProxy::select_source_url(&html, quality.as_deref()) else {
|
||||
return Ok(web::HttpResponse::BadGateway().finish());
|
||||
};
|
||||
|
||||
let mut headers = vec![("Referer".to_string(), detail_url)];
|
||||
if let Some(range) = req
|
||||
.headers()
|
||||
.get("Range")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
{
|
||||
headers.push(("Range".to_string(), range.to_string()));
|
||||
}
|
||||
|
||||
let upstream = match requester.get_raw_with_headers(&source_url, headers).await {
|
||||
Ok(response) => response,
|
||||
Err(_) => return Ok(web::HttpResponse::BadGateway().finish()),
|
||||
};
|
||||
|
||||
let status = upstream.status();
|
||||
let upstream_headers = upstream.headers().clone();
|
||||
let bytes = upstream.bytes().await.map_err(error::ErrorBadGateway)?;
|
||||
|
||||
let mut response = Response::build(status);
|
||||
if let Some(value) = upstream_headers
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
{
|
||||
response.set_header(CONTENT_TYPE, value);
|
||||
}
|
||||
if let Some(value) = upstream_headers
|
||||
.get(CONTENT_LENGTH)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
{
|
||||
response.set_header(CONTENT_LENGTH, value);
|
||||
}
|
||||
if let Some(value) = upstream_headers
|
||||
.get(CONTENT_RANGE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
{
|
||||
response.set_header(CONTENT_RANGE, value);
|
||||
}
|
||||
if let Some(value) = upstream_headers
|
||||
.get("Accept-Ranges")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
{
|
||||
response.set_header("Accept-Ranges", value);
|
||||
}
|
||||
|
||||
Ok(response.body(bytes.to_vec()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ShooshtimeProxy;
|
||||
|
||||
#[test]
|
||||
fn normalizes_detail_endpoint_and_quality() {
|
||||
let (url, quality) = ShooshtimeProxy::normalize_detail_request(
|
||||
"shooshtime.com/videos/example/123/__quality__/720p",
|
||||
)
|
||||
.expect("proxy target should parse");
|
||||
|
||||
assert_eq!(url, "https://shooshtime.com/videos/example/123/");
|
||||
assert_eq!(quality.as_deref(), Some("720p"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selects_requested_or_best_quality() {
|
||||
let html = r#"
|
||||
<script>
|
||||
var flashvars = {
|
||||
video_url: 'https://shooshtime.com/get_file/1/token/1/2/3.mp4/?x=1',
|
||||
video_url_text: '480p',
|
||||
video_alt_url: 'https://shooshtime.com/get_file/1/token/1/2/3_720p.mp4/?x=2',
|
||||
video_alt_url_text: '720p'
|
||||
};
|
||||
</script>
|
||||
"#;
|
||||
|
||||
assert_eq!(
|
||||
ShooshtimeProxy::select_source_url(html, Some("480p")).as_deref(),
|
||||
Some("https://shooshtime.com/get_file/1/token/1/2/3.mp4/?x=1")
|
||||
);
|
||||
assert_eq!(
|
||||
ShooshtimeProxy::select_source_url(html, None).as_deref(),
|
||||
Some("https://shooshtime.com/get_file/1/token/1/2/3_720p.mp4/?x=2")
|
||||
);
|
||||
}
|
||||
}
|
||||
105
src/proxies/spankbang.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
use ntex::web;
|
||||
use regex::Regex;
|
||||
use wreq::Version;
|
||||
|
||||
use crate::util::requester::Requester;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SpankbangProxy {}
|
||||
|
||||
impl SpankbangProxy {
|
||||
pub fn new() -> Self {
|
||||
SpankbangProxy {}
|
||||
}
|
||||
|
||||
fn request_headers() -> Vec<(String, String)> {
|
||||
vec![("Referer".to_string(), "https://spankbang.com/".to_string())]
|
||||
}
|
||||
|
||||
fn extract_stream_data(text: &str) -> Option<&str> {
|
||||
let marker = "var stream_data = ";
|
||||
let start = text.find(marker)? + marker.len();
|
||||
let rest = &text[start..];
|
||||
let end = rest.find("};")?;
|
||||
Some(&rest[..=end])
|
||||
}
|
||||
|
||||
fn extract_first_stream_url(stream_data: &str, key: &str) -> Option<String> {
|
||||
let pattern = format!(r"'{}'\s*:\s*\[\s*'([^']+)'", regex::escape(key));
|
||||
let regex = Regex::new(&pattern).ok()?;
|
||||
regex
|
||||
.captures(stream_data)
|
||||
.and_then(|captures| captures.get(1))
|
||||
.map(|value| value.as_str().to_string())
|
||||
}
|
||||
|
||||
fn select_best_stream_url(stream_data: &str) -> Option<String> {
|
||||
for key in [
|
||||
"m3u8", "4k", "1080p", "720p", "480p", "320p", "240p", "main",
|
||||
] {
|
||||
if let Some(url) = Self::extract_first_stream_url(stream_data, key) {
|
||||
return Some(url);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn get_video_url(
|
||||
&self,
|
||||
url: String,
|
||||
requester: web::types::State<Requester>,
|
||||
) -> String {
|
||||
let mut requester = requester.get_ref().clone();
|
||||
let url = format!("https://spankbang.com/{}", url.trim_start_matches('/'));
|
||||
let text = requester
|
||||
.get_with_headers(&url, Self::request_headers(), Some(Version::HTTP_2))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if text.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let Some(stream_data) = Self::extract_stream_data(&text) else {
|
||||
return String::new();
|
||||
};
|
||||
|
||||
Self::select_best_stream_url(stream_data).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::SpankbangProxy;
|
||||
|
||||
#[test]
|
||||
fn prefers_m3u8_when_present() {
|
||||
assert_eq!(
|
||||
SpankbangProxy::request_headers(),
|
||||
vec![("Referer".to_string(), "https://spankbang.com/".to_string())]
|
||||
);
|
||||
|
||||
let data = r#"
|
||||
var stream_data = {'240p': ['https://cdn.example/240.mp4'], '720p': ['https://cdn.example/720.mp4'], 'm3u8': ['https://cdn.example/master.m3u8'], 'main': ['https://cdn.example/720.mp4']};
|
||||
"#;
|
||||
|
||||
let stream_data = SpankbangProxy::extract_stream_data(data).unwrap();
|
||||
assert_eq!(
|
||||
SpankbangProxy::select_best_stream_url(stream_data).as_deref(),
|
||||
Some("https://cdn.example/master.m3u8")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_highest_quality_mp4() {
|
||||
let data = r#"
|
||||
var stream_data = {'240p': ['https://cdn.example/240.mp4'], '480p': ['https://cdn.example/480.mp4'], '720p': ['https://cdn.example/720.mp4'], '1080p': [], '4k': [], 'm3u8': [], 'main': ['https://cdn.example/480.mp4']};
|
||||
"#;
|
||||
|
||||
let stream_data = SpankbangProxy::extract_stream_data(data).unwrap();
|
||||
assert_eq!(
|
||||
SpankbangProxy::select_best_stream_url(stream_data).as_deref(),
|
||||
Some("https://cdn.example/720.mp4")
|
||||
);
|
||||
}
|
||||
}
|
||||
91
src/proxies/sxyprn.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
use base64::{Engine as _, engine::general_purpose};
|
||||
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();
|
||||
}
|
||||
}
|
||||
112
src/proxy.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
use ntex::web::{self, HttpRequest};
|
||||
|
||||
use crate::proxies::doodstream::DoodstreamProxy;
|
||||
use crate::proxies::javtiful::JavtifulProxy;
|
||||
use crate::proxies::pimpbunny::PimpbunnyProxy;
|
||||
use crate::proxies::porndish::PorndishProxy;
|
||||
use crate::proxies::pornhd3x::Pornhd3xProxy;
|
||||
use crate::proxies::spankbang::SpankbangProxy;
|
||||
use crate::proxies::sxyprn::SxyprnProxy;
|
||||
use crate::proxies::*;
|
||||
use crate::util::requester::Requester;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::resource("/doodstream/{endpoint}*")
|
||||
.route(web::post().to(proxy2redirect))
|
||||
.route(web::get().to(proxy2redirect)),
|
||||
)
|
||||
.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("/spankbang/{endpoint}*")
|
||||
.route(web::post().to(proxy2redirect))
|
||||
.route(web::get().to(proxy2redirect)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/porndish/{endpoint}*")
|
||||
.route(web::post().to(proxy2redirect))
|
||||
.route(web::get().to(proxy2redirect)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/pornhd3x/{endpoint}*")
|
||||
.route(web::post().to(proxy2redirect))
|
||||
.route(web::get().to(proxy2redirect)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/shooshtime/{endpoint}*")
|
||||
.route(web::post().to(crate::proxies::shooshtime::serve_media))
|
||||
.route(web::get().to(crate::proxies::shooshtime::serve_media)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/pimpbunny/{endpoint}*")
|
||||
.route(web::post().to(proxy2redirect))
|
||||
.route(web::get().to(proxy2redirect)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/noodlemagazine/{endpoint}*")
|
||||
.route(web::post().to(crate::proxies::noodlemagazine::serve_media))
|
||||
.route(web::get().to(crate::proxies::noodlemagazine::serve_media)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/noodlemagazine-thumb/{endpoint}*")
|
||||
.route(web::post().to(crate::proxies::noodlemagazine::get_image))
|
||||
.route(web::get().to(crate::proxies::noodlemagazine::get_image)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/hanime-cdn/{endpoint}*")
|
||||
.route(web::post().to(crate::proxies::hanimecdn::get_image))
|
||||
.route(web::get().to(crate::proxies::hanimecdn::get_image)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/hqporner-thumb/{endpoint}*")
|
||||
.route(web::post().to(crate::proxies::hqpornerthumb::get_image))
|
||||
.route(web::get().to(crate::proxies::hqpornerthumb::get_image)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/porndish-thumb/{endpoint}*")
|
||||
.route(web::post().to(crate::proxies::porndishthumb::get_image))
|
||||
.route(web::get().to(crate::proxies::porndishthumb::get_image)),
|
||||
);
|
||||
cfg.service(
|
||||
web::resource("/pimpbunny-thumb/{endpoint}*")
|
||||
.route(web::post().to(crate::proxies::pimpbunnythumb::get_image))
|
||||
.route(web::get().to(crate::proxies::pimpbunnythumb::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 {
|
||||
"doodstream" => Some(AnyProxy::Doodstream(DoodstreamProxy::new())),
|
||||
"sxyprn" => Some(AnyProxy::Sxyprn(SxyprnProxy::new())),
|
||||
"javtiful" => Some(AnyProxy::Javtiful(JavtifulProxy::new())),
|
||||
"pornhd3x" => Some(AnyProxy::Pornhd3x(Pornhd3xProxy::new())),
|
||||
"pimpbunny" => Some(AnyProxy::Pimpbunny(PimpbunnyProxy::new())),
|
||||
"porndish" => Some(AnyProxy::Porndish(PorndishProxy::new())),
|
||||
"spankbang" => Some(AnyProxy::Spankbang(SpankbangProxy::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,
|
||||
}
|
||||
}
|
||||
@@ -16,25 +16,36 @@ pub struct Channel {
|
||||
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<Channel_Option>,
|
||||
pub options: Vec<ChannelOption>,
|
||||
pub nsfw: bool, //true
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cacheDuration: Option<u32>, //Some(86400)
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, Debug, Clone)]
|
||||
pub struct ChannelGroup {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub systemImage: Option<String>,
|
||||
pub channelIds: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct Channel_Option {
|
||||
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<Filter_Option>, //[],
|
||||
pub multiSelect: bool, //true
|
||||
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)]
|
||||
pub struct Filter_Option{
|
||||
pub id: String, //"sort",
|
||||
pub title: String, //"Sort",
|
||||
#[derive(serde::Serialize, Debug, Clone)]
|
||||
pub struct FilterOption {
|
||||
pub id: String, //"sort",
|
||||
pub title: String, //"Sort",
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
@@ -44,11 +55,11 @@ pub struct Options {
|
||||
pub description: String, //"Sort the videos by new or old.",
|
||||
pub systemImage: String, //"sort.image",
|
||||
pub colorName: String, //"blue",
|
||||
pub options: Vec<Option_Value>,
|
||||
pub options: Vec<OptionValue>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct Option_Value {
|
||||
pub struct OptionValue {
|
||||
pub id: String, //"new",
|
||||
pub title: String, //"New",
|
||||
pub description: Option<String>, //"Sort the videos by new or old."
|
||||
@@ -107,16 +118,49 @@ impl Status {
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
pub fn add_notice(&mut self, notice: Notice) {
|
||||
self.notices.push(notice);
|
||||
}
|
||||
pub fn add_channel(&mut self, channel: Channel) {
|
||||
self.channels.push(channel);
|
||||
}
|
||||
pub fn add_option(&mut self, option: Options) {
|
||||
self.options.push(option);
|
||||
}
|
||||
pub fn add_category(&mut self, category: String) {
|
||||
self.categories.push(category);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct ChannelView {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub premium: bool,
|
||||
pub favicon: String,
|
||||
pub status: String,
|
||||
pub categories: Vec<String>,
|
||||
pub options: Vec<ChannelOption>,
|
||||
pub nsfw: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub groupKey: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sortOrder: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tags: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ytdlpCommand: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cacheDuration: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct StatusResponse {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub subtitle: String,
|
||||
pub description: String,
|
||||
pub iconUrl: String,
|
||||
pub color: String,
|
||||
pub status: String,
|
||||
pub notices: Vec<Notice>,
|
||||
pub channels: Vec<ChannelView>,
|
||||
pub channelGroups: Vec<ChannelGroup>,
|
||||
pub subscription: Subscription,
|
||||
pub nsfw: bool,
|
||||
pub categories: Vec<String>,
|
||||
pub options: Vec<Options>,
|
||||
pub filtersFooter: String,
|
||||
}
|
||||
|
||||
216
src/uploaders.rs
Normal file
@@ -0,0 +1,216 @@
|
||||
use chrono::{SecondsFormat, TimeZone, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::videos::VideoItem;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
pub struct UploadersRequest {
|
||||
#[serde(default, alias = "uploader_id")]
|
||||
pub uploaderId: Option<String>,
|
||||
#[serde(default, alias = "uploader_name")]
|
||||
pub uploaderName: Option<String>,
|
||||
#[serde(default, alias = "profile_content")]
|
||||
pub profileContent: bool,
|
||||
#[serde(default)]
|
||||
pub query: Option<String>,
|
||||
}
|
||||
|
||||
impl UploadersRequest {
|
||||
pub fn normalized(self) -> Self {
|
||||
Self {
|
||||
uploaderId: normalize_optional_string(self.uploaderId),
|
||||
uploaderName: normalize_optional_string(self.uploaderName),
|
||||
profileContent: self.profileContent,
|
||||
query: normalize_optional_string(self.query),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
pub struct UploaderProfile {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub url: Option<String>,
|
||||
pub channel: Option<String>,
|
||||
pub verified: bool,
|
||||
pub videoCount: u64,
|
||||
pub totalViews: u64,
|
||||
#[serde(default)]
|
||||
pub channels: Option<Vec<UploaderChannelStat>>,
|
||||
#[serde(default, alias = "profile_picture_url")]
|
||||
pub avatar: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
#[serde(default)]
|
||||
pub videos: Option<Vec<UploaderVideoRef>>,
|
||||
#[serde(default)]
|
||||
pub tapes: Option<Vec<serde_json::Value>>,
|
||||
#[serde(default)]
|
||||
pub playlists: Option<Vec<serde_json::Value>>,
|
||||
#[serde(default)]
|
||||
pub layout: Option<Vec<UploaderLayoutRow>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
pub struct UploaderChannelStat {
|
||||
pub channel: String,
|
||||
pub videoCount: u64,
|
||||
#[serde(default, alias = "first_seen_at")]
|
||||
pub firstSeenAt: Option<String>,
|
||||
#[serde(default, alias = "last_seen_at")]
|
||||
pub lastSeenAt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
pub struct UploaderVideoRef {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
pub title: String,
|
||||
pub duration: u32,
|
||||
pub channel: String,
|
||||
#[serde(default, alias = "uploaded_at")]
|
||||
pub uploadedAt: Option<String>,
|
||||
pub uploader: String,
|
||||
#[serde(alias = "uploader_id")]
|
||||
pub uploaderId: String,
|
||||
pub thumb: String,
|
||||
pub preview: Option<String>,
|
||||
pub views: u32,
|
||||
pub rating: u32,
|
||||
#[serde(default, alias = "aspect_ratio")]
|
||||
pub aspectRatio: Option<f32>,
|
||||
}
|
||||
|
||||
impl UploaderVideoRef {
|
||||
pub fn from_video_item(item: &VideoItem, uploader_name: &str, uploader_id: &str) -> Self {
|
||||
Self {
|
||||
id: item.id.clone(),
|
||||
url: item.url.clone(),
|
||||
title: item.title.clone(),
|
||||
duration: item.duration,
|
||||
channel: item.channel.clone(),
|
||||
uploadedAt: iso_timestamp_from_unix(item.uploadedAt),
|
||||
uploader: item
|
||||
.uploader
|
||||
.clone()
|
||||
.unwrap_or_else(|| uploader_name.to_string()),
|
||||
uploaderId: item
|
||||
.uploaderId
|
||||
.clone()
|
||||
.unwrap_or_else(|| uploader_id.to_string()),
|
||||
thumb: item.thumb.clone(),
|
||||
preview: item.preview.clone(),
|
||||
views: item.views.unwrap_or_default(),
|
||||
rating: item.rating.map(normalize_rating).unwrap_or_default(),
|
||||
aspectRatio: item.aspectRatio,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
pub struct UploaderLayoutRow {
|
||||
#[serde(rename = "type")]
|
||||
pub rowType: UploaderLayoutRowType,
|
||||
pub title: Option<String>,
|
||||
#[serde(default, alias = "video_ids")]
|
||||
pub videoIds: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl UploaderLayoutRow {
|
||||
pub fn horizontal(title: Option<String>, video_ids: Vec<String>) -> Self {
|
||||
Self {
|
||||
rowType: UploaderLayoutRowType::Horizontal,
|
||||
title,
|
||||
videoIds: Some(video_ids),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn videos(title: Option<String>) -> Self {
|
||||
Self {
|
||||
rowType: UploaderLayoutRowType::Videos,
|
||||
title,
|
||||
videoIds: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub enum UploaderLayoutRowType {
|
||||
#[default]
|
||||
#[serde(rename = "videos")]
|
||||
Videos,
|
||||
#[serde(rename = "horizontal", alias = "horizontal_videos")]
|
||||
Horizontal,
|
||||
}
|
||||
|
||||
pub fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|value| {
|
||||
let trimmed = value.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn iso_timestamp_from_unix(value: Option<u64>) -> Option<String> {
|
||||
let timestamp = value?;
|
||||
let dt = Utc.timestamp_opt(timestamp as i64, 0).single()?;
|
||||
Some(dt.to_rfc3339_opts(SecondsFormat::Millis, true))
|
||||
}
|
||||
|
||||
fn normalize_rating(value: f32) -> u32 {
|
||||
value.clamp(0.0, 100.0).round() as u32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn request_accepts_snake_case_aliases() {
|
||||
let request: UploadersRequest = serde_json::from_str(
|
||||
r#"{
|
||||
"uploader_id": "hsex:xihongshiddd",
|
||||
"uploader_name": "xihongshiddd",
|
||||
"profile_content": true,
|
||||
"query": "teacher"
|
||||
}"#,
|
||||
)
|
||||
.expect("request should decode");
|
||||
|
||||
assert_eq!(request.uploaderId.as_deref(), Some("hsex:xihongshiddd"));
|
||||
assert_eq!(request.uploaderName.as_deref(), Some("xihongshiddd"));
|
||||
assert!(request.profileContent);
|
||||
assert_eq!(request.query.as_deref(), Some("teacher"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_aliases_decode() {
|
||||
let row: UploaderLayoutRow = serde_json::from_str(
|
||||
r#"{
|
||||
"type": "horizontal_videos",
|
||||
"title": "For You",
|
||||
"video_ids": ["one", "two"]
|
||||
}"#,
|
||||
)
|
||||
.expect("row should decode");
|
||||
|
||||
assert_eq!(row.rowType, UploaderLayoutRowType::Horizontal);
|
||||
assert_eq!(row.videoIds.as_ref().map(Vec::len), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn avatar_alias_decodes() {
|
||||
let profile: UploaderProfile = serde_json::from_str(
|
||||
r#"{
|
||||
"id": "abc",
|
||||
"name": "Example",
|
||||
"verified": false,
|
||||
"videoCount": 1,
|
||||
"totalViews": 2,
|
||||
"profile_picture_url": "https://example.com/a.jpg"
|
||||
}"#,
|
||||
)
|
||||
.expect("profile should decode");
|
||||
|
||||
assert_eq!(profile.avatar.as_deref(), Some("https://example.com/a.jpg"));
|
||||
}
|
||||
}
|
||||
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 crate::util::requester;
|
||||
use dashmap::DashMap;
|
||||
use once_cell::sync::Lazy;
|
||||
use serde_json::json;
|
||||
use std::error::Error;
|
||||
use std::fmt::Write as _;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
// 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;
|
||||
}
|
||||
100
src/util/flaresolverr.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
41
src/util/flow_debug.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
#[cfg(feature = "debug")]
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
static NEXT_TRACE_ID: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
pub fn next_trace_id(prefix: &str) -> String {
|
||||
let id = NEXT_TRACE_ID.fetch_add(1, Ordering::Relaxed);
|
||||
format!("{prefix}-{id:06}")
|
||||
}
|
||||
|
||||
#[cfg(feature = "debug")]
|
||||
pub fn emit(module: &str, line: u32, message: String) {
|
||||
let millis = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or_default();
|
||||
eprintln!("[debug][{millis}][{module}:{line}] {message}");
|
||||
}
|
||||
|
||||
pub fn preview(value: &str, limit: usize) -> String {
|
||||
if value.len() <= limit {
|
||||
return value.to_string();
|
||||
}
|
||||
|
||||
let mut end = limit;
|
||||
while !value.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
format!("{}...", &value[..end])
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! flow_debug {
|
||||
($($arg:tt)*) => {{
|
||||
#[cfg(feature = "debug")]
|
||||
{
|
||||
$crate::util::flow_debug::emit(module_path!(), line!(), format!($($arg)*));
|
||||
}
|
||||
}};
|
||||
}
|
||||