Compare commits
476 Commits
master
...
b8c326306d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8c326306d | ||
|
|
041460d9b9 | ||
|
|
c0717fdacf | ||
|
|
e680319541 | ||
|
|
543e025dda | ||
|
|
067ff3d1da | ||
|
|
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
|
||||
47
README.md
@@ -4,4 +4,49 @@ Rust based hottub server
|
||||
|
||||
the following URL:
|
||||
|
||||
[hottub.spacemoehre.de](hottub://source?url=hottub.spacemoehre.de)
|
||||
[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))
|
||||
}
|
||||
}
|
||||
365
build.rs
Normal file
@@ -0,0 +1,365 @@
|
||||
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: "supjav",
|
||||
module: "supjav",
|
||||
ty: "SupjavProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "vjav",
|
||||
module: "vjav",
|
||||
ty: "VjavProvider",
|
||||
},
|
||||
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
|
||||
|
||||
|
||||
2040
docs/hottubapp/🎬 Videos - Hot Tub Docs.html
Normal file
1761
docs/hottubapp/👤 Uploaders - Hot Tub Docs.html
Normal file
2231
docs/hottubapp/📡 Status - Hot Tub Docs.html
Normal file
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
|
||||
)
|
||||
1
prompts/new-channel.md
Normal file
@@ -0,0 +1 @@
|
||||
write a provider for the site "vjav.com". use playwright-mcp to get the index site and get the layout of videos from this. also figure out how searches work and if it has a different layout then. also find out how more videos get loaded/more pages urls. also find if it has video sites like recommended,most views etc and include them. try to find as much information about a video on the site as possible and build the video items with all the information. put tags and uploader into lists so they can be utilized later. on query check if the search already exists in these FilterObject arrays and use them for the url instead (similar to omgxxx). after coding this, test that all provided urls work, yt-dlp can download the video.url, the thumbnails work, searches, tag-/uploader- searches work, more pages work. Keep a key-value storage for all provided Tag titles to tag IDs to do the correct crawl for queries of a tag. analzye the provider too and set the channel tags and put it in a group that matches this provider or create a new group where it fits in. do not include the embed field in the videoitems responses. when parsing uploaders, make sure that the uploaders. if unsure about the endpoints, check with the docs dir.
|
||||
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))
|
||||
}
|
||||
}
|
||||
1737
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
977
src/providers/pornhub.rs
Normal file
@@ -0,0 +1,977 @@
|
||||
use crate::DbPool;
|
||||
use crate::api::ClientVersion;
|
||||
use crate::providers::{
|
||||
Provider, report_provider_error, report_provider_error_background, 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 chrono::{DateTime, NaiveDate, Utc};
|
||||
use error_chain::error_chain;
|
||||
use futures::stream::{self, StreamExt};
|
||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||
use regex::Regex;
|
||||
use scraper::{ElementRef, Html, Selector};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
use url::Url;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "mainstream-tube",
|
||||
tags: &["mainstream", "studio", "general"],
|
||||
};
|
||||
|
||||
const BASE_URL: &str = "https://www.pornhub.com";
|
||||
const CHANNEL_ID: &str = "pornhub";
|
||||
const DETAIL_ENRICH_LIMIT: usize = 12;
|
||||
|
||||
error_chain! {
|
||||
foreign_links {
|
||||
Io(std::io::Error);
|
||||
HttpRequest(wreq::Error);
|
||||
Json(serde_json::Error);
|
||||
Url(url::ParseError);
|
||||
}
|
||||
errors {
|
||||
Parse(msg: String) {
|
||||
description("parse error")
|
||||
display("parse error: {}", msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PornhubProvider {
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum ListingScope {
|
||||
Browse,
|
||||
Search,
|
||||
Creator,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum QueryTargetKind {
|
||||
Channel,
|
||||
Pornstar,
|
||||
Model,
|
||||
User,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct QueryTarget {
|
||||
kind: QueryTargetKind,
|
||||
slug: String,
|
||||
}
|
||||
|
||||
impl QueryTargetKind {
|
||||
fn path_segment(self) -> &'static str {
|
||||
match self {
|
||||
Self::Channel => "channels",
|
||||
Self::Pornstar => "pornstar",
|
||||
Self::Model => "model",
|
||||
Self::User => "users",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PornhubProvider {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
url: BASE_URL.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
|
||||
Channel {
|
||||
id: CHANNEL_ID.to_string(),
|
||||
name: "Pornhub".to_string(),
|
||||
description: "Pornhub listings with creator queries and direct HLS playback links."
|
||||
.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: "Browse Pornhub charts by sort order.".to_string(),
|
||||
systemImage: "list.number".to_string(),
|
||||
colorName: "blue".to_string(),
|
||||
options: vec![
|
||||
FilterOption {
|
||||
id: "mr".to_string(),
|
||||
title: "Featured Recently".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "mv".to_string(),
|
||||
title: "Most Viewed".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "tr".to_string(),
|
||||
title: "Top Rated".to_string(),
|
||||
},
|
||||
FilterOption {
|
||||
id: "ht".to_string(),
|
||||
title: "Hottest".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),
|
||||
}
|
||||
}
|
||||
|
||||
fn selector(value: &str) -> Result<Selector> {
|
||||
Selector::parse(value)
|
||||
.map_err(|error| ErrorKind::Parse(format!("selector parse failed for {value}: {error}")).into())
|
||||
}
|
||||
|
||||
fn regex(value: &str) -> Result<Regex> {
|
||||
Regex::new(value)
|
||||
.map_err(|error| ErrorKind::Parse(format!("regex parse failed for {value}: {error}")).into())
|
||||
}
|
||||
|
||||
fn text_of(element: &ElementRef<'_>) -> String {
|
||||
element
|
||||
.text()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn decode_html(value: &str) -> String {
|
||||
decode(value.as_bytes())
|
||||
.to_string()
|
||||
.unwrap_or_else(|_| value.to_string())
|
||||
}
|
||||
|
||||
fn normalize_url(&self, value: &str) -> String {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
if let Ok(url) = Url::parse(trimmed) {
|
||||
return url.to_string();
|
||||
}
|
||||
Url::parse(BASE_URL)
|
||||
.and_then(|base| base.join(trimmed))
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn normalize_sort(sort: &str) -> &'static str {
|
||||
match sort.trim().to_ascii_lowercase().as_str() {
|
||||
"mv" => "mv",
|
||||
"tr" => "tr",
|
||||
"ht" => "ht",
|
||||
"lg" => "lg",
|
||||
"cm" => "cm",
|
||||
"mr" | "new" => "mr",
|
||||
value if value.contains("date") => "mr",
|
||||
_ => "mr",
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_query_target(query: &str) -> Option<QueryTarget> {
|
||||
let trimmed = query.trim();
|
||||
let trimmed = trimmed.strip_prefix('@')?;
|
||||
let (kind, raw_slug) = trimmed.split_once(':')?;
|
||||
let slug = raw_slug
|
||||
.trim()
|
||||
.trim_matches('/')
|
||||
.replace(' ', "-")
|
||||
.to_ascii_lowercase();
|
||||
if slug.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let kind = match kind.trim().to_ascii_lowercase().as_str() {
|
||||
"channel" | "channels" => QueryTargetKind::Channel,
|
||||
"pornstar" | "pornstars" => QueryTargetKind::Pornstar,
|
||||
"model" | "models" => QueryTargetKind::Model,
|
||||
"user" | "users" => QueryTargetKind::User,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(QueryTarget { kind, slug })
|
||||
}
|
||||
|
||||
fn build_browse_url(&self, page: u8, sort: &str) -> String {
|
||||
let order = Self::normalize_sort(sort);
|
||||
if order == "mr" {
|
||||
format!("{}/video?page={page}", self.url)
|
||||
} else {
|
||||
format!("{}/video?o={order}&page={page}", self.url)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_creator_url(&self, page: u8, sort: &str, target: &QueryTarget) -> String {
|
||||
let mut url = format!(
|
||||
"{}/{}/{}/videos?page={page}",
|
||||
self.url,
|
||||
target.kind.path_segment(),
|
||||
target.slug
|
||||
);
|
||||
|
||||
let mapped_sort = match target.kind {
|
||||
QueryTargetKind::Channel => match Self::normalize_sort(sort) {
|
||||
"mv" => Some("vi"),
|
||||
"tr" => Some("ra"),
|
||||
_ => None,
|
||||
},
|
||||
_ => match Self::normalize_sort(sort) {
|
||||
"mv" => Some("mv"),
|
||||
"tr" => Some("tr"),
|
||||
"lg" => Some("lg"),
|
||||
_ => None,
|
||||
},
|
||||
};
|
||||
|
||||
if let Some(order) = mapped_sort {
|
||||
url.push_str("&o=");
|
||||
url.push_str(order);
|
||||
}
|
||||
|
||||
url
|
||||
}
|
||||
|
||||
fn build_listing_request(&self, page: u8, sort: &str, query: Option<&str>) -> (String, ListingScope) {
|
||||
match query.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
Some(query) => {
|
||||
if let Some(target) = Self::parse_query_target(query) {
|
||||
(self.build_creator_url(page, sort, &target), ListingScope::Creator)
|
||||
} else {
|
||||
let encoded = query.to_ascii_lowercase().replace(' ', "+");
|
||||
(
|
||||
format!("{}/video/search?search={encoded}&page={page}", self.url),
|
||||
ListingScope::Search,
|
||||
)
|
||||
}
|
||||
}
|
||||
None => (self.build_browse_url(page, sort), ListingScope::Browse),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_listing_page(&self, html: &str, scope: ListingScope) -> Result<Vec<VideoItem>> {
|
||||
let document = Html::parse_document(html);
|
||||
let item_selector = Self::selector("li.pcVideoListItem")?;
|
||||
|
||||
let container_selectors = match scope {
|
||||
ListingScope::Browse => vec!["#videoCategory"],
|
||||
ListingScope::Search => vec!["#videoSearchResult"],
|
||||
ListingScope::Creator => vec!["#showAllChanelVideos", "#mostRecentVideosSection"],
|
||||
};
|
||||
|
||||
for selector_text in container_selectors {
|
||||
let container_selector = Self::selector(selector_text)?;
|
||||
if let Some(container) = document.select(&container_selector).next() {
|
||||
if container.select(&item_selector).next().is_some() {
|
||||
return self.parse_listing_items(container);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(ErrorKind::Parse(format!("missing listing container for scope {scope:?}")).into())
|
||||
}
|
||||
|
||||
fn parse_listing_items(&self, container: ElementRef<'_>) -> Result<Vec<VideoItem>> {
|
||||
let item_selector = Self::selector("li.pcVideoListItem")?;
|
||||
let link_selector = Self::selector("a[href*=\"/view_video.php\"]")?;
|
||||
let title_selector = Self::selector(".title a, .thumbnailTitle, span.title a")?;
|
||||
let image_selector = Self::selector("img")?;
|
||||
let duration_selector = Self::selector(".duration")?;
|
||||
let views_selector = Self::selector(".views var")?;
|
||||
let uploader_selector = Self::selector(
|
||||
".videoUploaderBlock a[href], .usernameWrap a[href], .usernameWrapper a[href]",
|
||||
)?;
|
||||
let verified_selector = Self::selector(".verified-icon, .channel-icon")?;
|
||||
|
||||
let mut items = Vec::new();
|
||||
let mut seen_ids = HashSet::new();
|
||||
|
||||
for card in container.select(&item_selector) {
|
||||
let Some(link) = card.select(&link_selector).next() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let href = link.value().attr("href").unwrap_or_default();
|
||||
let page_url = self.normalize_url(href);
|
||||
if page_url.is_empty() || !page_url.contains("/view_video.php") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let id = card
|
||||
.value()
|
||||
.attr("data-video-vkey")
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| Self::viewkey_from_url(&page_url))
|
||||
.or_else(|| {
|
||||
card.value()
|
||||
.attr("data-video-id")
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
});
|
||||
let Some(id) = id else {
|
||||
continue;
|
||||
};
|
||||
if !seen_ids.insert(id.clone()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let title = link
|
||||
.value()
|
||||
.attr("title")
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(Self::decode_html)
|
||||
.or_else(|| {
|
||||
card.select(&title_selector)
|
||||
.next()
|
||||
.map(|value| Self::decode_html(&Self::text_of(&value)))
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if title.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let image = card.select(&image_selector).next();
|
||||
let thumb = image
|
||||
.as_ref()
|
||||
.and_then(|value| {
|
||||
value
|
||||
.value()
|
||||
.attr("src")
|
||||
.or_else(|| value.value().attr("data-mediumthumb"))
|
||||
})
|
||||
.map(|value| self.normalize_url(value))
|
||||
.unwrap_or_default();
|
||||
|
||||
let duration = card
|
||||
.select(&duration_selector)
|
||||
.next()
|
||||
.map(|value| Self::text_of(&value))
|
||||
.and_then(|value| parse_time_to_seconds(&value))
|
||||
.unwrap_or(0) as u32;
|
||||
|
||||
let views = card
|
||||
.select(&views_selector)
|
||||
.next()
|
||||
.and_then(|value| parse_abbreviated_number(&Self::text_of(&value)));
|
||||
|
||||
let uploader_link = card.select(&uploader_selector).next();
|
||||
let uploader = uploader_link
|
||||
.as_ref()
|
||||
.map(|value| Self::decode_html(&Self::text_of(value)))
|
||||
.filter(|value| !value.is_empty());
|
||||
let uploader_url = uploader_link
|
||||
.and_then(|value| value.value().attr("href"))
|
||||
.map(|value| self.normalize_url(value))
|
||||
.filter(|value| !value.is_empty());
|
||||
|
||||
let mut item = VideoItem::new(
|
||||
id,
|
||||
title,
|
||||
page_url,
|
||||
CHANNEL_ID.to_string(),
|
||||
thumb,
|
||||
duration,
|
||||
);
|
||||
item.views = views;
|
||||
item.preview = image
|
||||
.and_then(|value| value.value().attr("data-mediabook"))
|
||||
.map(|value| self.normalize_url(value))
|
||||
.filter(|value| !value.is_empty());
|
||||
item.verified = card
|
||||
.select(&verified_selector)
|
||||
.next()
|
||||
.map(|_| true);
|
||||
item.uploader = uploader.clone();
|
||||
item.uploaderUrl = uploader_url.clone();
|
||||
item.uploaderId = uploader_url
|
||||
.as_deref()
|
||||
.and_then(Self::uploader_identity_from_url);
|
||||
|
||||
let mut tags = Vec::new();
|
||||
if let Some(tag) = uploader_url
|
||||
.as_deref()
|
||||
.and_then(Self::query_tag_from_uploader_url)
|
||||
{
|
||||
tags.push(tag);
|
||||
}
|
||||
if !tags.is_empty() {
|
||||
item.tags = Some(tags);
|
||||
}
|
||||
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
fn viewkey_from_url(url: &str) -> Option<String> {
|
||||
let parsed = Url::parse(url).ok()?;
|
||||
parsed
|
||||
.query_pairs()
|
||||
.find(|(key, _)| key == "viewkey")
|
||||
.map(|(_, value)| value.into_owned())
|
||||
}
|
||||
|
||||
fn uploader_identity_from_url(url: &str) -> Option<String> {
|
||||
let parsed = Url::parse(url).ok()?;
|
||||
let mut segments = parsed.path_segments()?;
|
||||
let kind = segments.next()?.trim_matches('/');
|
||||
let slug = segments.next()?.trim_matches('/');
|
||||
if kind.is_empty() || slug.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(format!("{CHANNEL_ID}:{kind}:{slug}"))
|
||||
}
|
||||
|
||||
fn query_tag_from_uploader_url(url: &str) -> Option<String> {
|
||||
let parsed = Url::parse(url).ok()?;
|
||||
let mut segments = parsed.path_segments()?;
|
||||
let kind = segments.next()?.trim_matches('/');
|
||||
let slug = segments.next()?.trim_matches('/');
|
||||
if kind.is_empty() || slug.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(format!("@{kind}:{}", slug.replace('-', " ")))
|
||||
}
|
||||
|
||||
fn push_unique(values: &mut Vec<String>, value: String) {
|
||||
let normalized = value.trim();
|
||||
if normalized.is_empty() {
|
||||
return;
|
||||
}
|
||||
if values.iter().any(|existing| existing.eq_ignore_ascii_case(normalized)) {
|
||||
return;
|
||||
}
|
||||
values.push(normalized.to_string());
|
||||
}
|
||||
|
||||
fn collect_named_links(&self, document: &Html, selector_text: &str) -> Result<Vec<String>> {
|
||||
let selector = Self::selector(selector_text)?;
|
||||
let mut values = Vec::new();
|
||||
for element in document.select(&selector) {
|
||||
Self::push_unique(&mut values, Self::decode_html(&Self::text_of(&element)));
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
fn parse_upload_date(value: &str) -> Option<u64> {
|
||||
if let Ok(parsed) = DateTime::parse_from_rfc3339(value.trim()) {
|
||||
return Some(parsed.timestamp() as u64);
|
||||
}
|
||||
|
||||
NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d")
|
||||
.ok()
|
||||
.and_then(|date| date.and_hms_opt(0, 0, 0))
|
||||
.map(|date| DateTime::<Utc>::from_naive_utc_and_offset(date, Utc).timestamp() as u64)
|
||||
}
|
||||
|
||||
fn json_string(value: Option<&Value>) -> Option<String> {
|
||||
value.and_then(|value| match value {
|
||||
Value::String(value) => Some(value.to_string()),
|
||||
Value::Number(value) => Some(value.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn json_u32(value: Option<&Value>) -> Option<u32> {
|
||||
match value {
|
||||
Some(Value::Number(value)) => value.as_u64().and_then(|value| u32::try_from(value).ok()),
|
||||
Some(Value::String(value)) => value.parse::<u32>().ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_flashvars(&self, html: &str) -> Result<Option<Value>> {
|
||||
let regex = Self::regex(r#"(?s)var\s+flashvars_\d+\s*=\s*(\{.*?\});"#)?;
|
||||
let Some(raw) = regex
|
||||
.captures(html)
|
||||
.and_then(|captures| captures.get(1))
|
||||
.map(|value| value.as_str())
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(Some(serde_json::from_str::<Value>(raw)?))
|
||||
}
|
||||
|
||||
fn extract_ld_video_object(&self, document: &Html) -> Result<Option<Value>> {
|
||||
let script_selector = Self::selector("script[type=\"application/ld+json\"]")?;
|
||||
for script in document.select(&script_selector) {
|
||||
let raw = script.inner_html();
|
||||
let Ok(value) = serde_json::from_str::<Value>(&raw) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if Self::is_video_object(&value) {
|
||||
return Ok(Some(value));
|
||||
}
|
||||
|
||||
if let Some(array) = value.as_array() {
|
||||
for entry in array {
|
||||
if Self::is_video_object(entry) {
|
||||
return Ok(Some(entry.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn is_video_object(value: &Value) -> bool {
|
||||
value
|
||||
.get("@type")
|
||||
.and_then(|value| value.as_str())
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("VideoObject"))
|
||||
}
|
||||
|
||||
fn build_formats_from_flashvars(&self, flashvars: &Value) -> Vec<VideoFormat> {
|
||||
let mut entries = flashvars
|
||||
.get("mediaDefinitions")
|
||||
.and_then(|value| value.as_array())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|entry| {
|
||||
let format = entry
|
||||
.get("format")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
if format != "hls" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let url = entry
|
||||
.get("videoUrl")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|value| self.normalize_url(value))
|
||||
.filter(|value| !value.is_empty())?;
|
||||
|
||||
let quality = entry
|
||||
.get("quality")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("auto");
|
||||
let label = match quality {
|
||||
"auto" => "auto".to_string(),
|
||||
value if value.ends_with('p') => value.to_string(),
|
||||
value => format!("{value}p"),
|
||||
};
|
||||
|
||||
let rank = if label == "auto" {
|
||||
0
|
||||
} else {
|
||||
label
|
||||
.trim_end_matches('p')
|
||||
.parse::<u32>()
|
||||
.unwrap_or(0)
|
||||
};
|
||||
|
||||
Some((rank, label, url))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
entries.sort_by_key(|(rank, _, _)| *rank);
|
||||
entries.dedup_by(|a, b| a.2 == b.2);
|
||||
|
||||
entries
|
||||
.into_iter()
|
||||
.map(|(_, label, url)| {
|
||||
VideoFormat::new(url, label.clone(), "m3u8".to_string())
|
||||
.format_id(label.clone())
|
||||
.format_note(label)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn apply_detail_video(&self, mut item: VideoItem, html: &str) -> Result<VideoItem> {
|
||||
let document = Html::parse_document(html);
|
||||
|
||||
if let Some(flashvars) = self.extract_flashvars(html)? {
|
||||
if let Some(title) = Self::json_string(flashvars.get("video_title")) {
|
||||
let decoded = Self::decode_html(&title);
|
||||
if !decoded.is_empty() {
|
||||
item.title = decoded;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(thumb) = Self::json_string(flashvars.get("image_url")) {
|
||||
let normalized = self.normalize_url(&thumb);
|
||||
if !normalized.is_empty() {
|
||||
item.thumb = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(duration) = Self::json_u32(flashvars.get("video_duration")) {
|
||||
item.duration = duration;
|
||||
}
|
||||
|
||||
if let Some(link_url) = Self::json_string(flashvars.get("link_url")) {
|
||||
let normalized = self.normalize_url(&link_url);
|
||||
if !normalized.is_empty() {
|
||||
item.url = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
let formats = self.build_formats_from_flashvars(&flashvars);
|
||||
if !formats.is_empty() {
|
||||
item.formats = Some(formats);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ld_video) = self.extract_ld_video_object(&document)? {
|
||||
if let Some(thumb) = ld_video
|
||||
.get("thumbnailUrl")
|
||||
.and_then(|value| match value {
|
||||
Value::String(value) => Some(value.to_string()),
|
||||
Value::Array(values) => values
|
||||
.iter()
|
||||
.find_map(|entry| entry.as_str().map(ToOwned::to_owned)),
|
||||
_ => None,
|
||||
})
|
||||
{
|
||||
let normalized = self.normalize_url(&thumb);
|
||||
if !normalized.is_empty() {
|
||||
item.thumb = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(uploaded_at) = ld_video
|
||||
.get("uploadDate")
|
||||
.and_then(|value| value.as_str())
|
||||
.and_then(Self::parse_upload_date)
|
||||
{
|
||||
item.uploadedAt = Some(uploaded_at);
|
||||
}
|
||||
|
||||
if item.views.is_none() {
|
||||
item.views = Self::json_string(ld_video.get("interactionCount"))
|
||||
.and_then(|value| value.parse::<u32>().ok());
|
||||
}
|
||||
|
||||
if item.uploader.is_none() {
|
||||
item.uploader = ld_video
|
||||
.get("author")
|
||||
.and_then(|value| match value {
|
||||
Value::String(value) => Some(value.to_string()),
|
||||
Value::Object(values) => values
|
||||
.get("name")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(ToOwned::to_owned),
|
||||
_ => None,
|
||||
})
|
||||
.filter(|value| !value.trim().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
let mut tags = item.tags.clone().unwrap_or_default();
|
||||
for value in self.collect_named_links(
|
||||
&document,
|
||||
".categoriesWrapper a.item, .categoriesWrapper a[href*=\"/categories/\"]",
|
||||
)? {
|
||||
Self::push_unique(&mut tags, value);
|
||||
}
|
||||
for value in self.collect_named_links(
|
||||
&document,
|
||||
".tagsWrapper a.item, .tagsWrapper a[href*=\"/video/search\"]",
|
||||
)? {
|
||||
Self::push_unique(&mut tags, value);
|
||||
}
|
||||
for value in self.collect_named_links(
|
||||
&document,
|
||||
".pornstarsWrapper a.item, .pornstarsWrapper a[href*=\"/pornstar/\"], a[href*=\"/pornstar/\"]",
|
||||
)? {
|
||||
Self::push_unique(&mut tags, value);
|
||||
}
|
||||
for value in self.collect_named_links(
|
||||
&document,
|
||||
".modelsWrapper a.item, .modelsWrapper a[href*=\"/model/\"], a[href*=\"/model/\"]",
|
||||
)? {
|
||||
Self::push_unique(&mut tags, value);
|
||||
}
|
||||
if !tags.is_empty() {
|
||||
item.tags = Some(tags);
|
||||
}
|
||||
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
async fn enrich_listing_items(&self, items: Vec<VideoItem>, options: &ServerOptions) -> Vec<VideoItem> {
|
||||
let requester = requester_or_default(options, CHANNEL_ID, "enrich_listing_items.requester");
|
||||
let mut enriched = stream::iter(items.into_iter().enumerate().map(|(index, item)| {
|
||||
let provider = self.clone();
|
||||
let requester = requester.clone();
|
||||
async move {
|
||||
if index >= DETAIL_ENRICH_LIMIT || item.url.is_empty() {
|
||||
return (index, item);
|
||||
}
|
||||
|
||||
let fallback = item.clone();
|
||||
let enriched = match provider.fetch_detail(item, requester).await {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
report_provider_error_background(
|
||||
CHANNEL_ID,
|
||||
"enrich_listing_items.detail",
|
||||
&format!("url={}; error={error}", fallback.url),
|
||||
);
|
||||
fallback
|
||||
}
|
||||
};
|
||||
(index, enriched)
|
||||
}
|
||||
}))
|
||||
.buffer_unordered(4)
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
|
||||
enriched.sort_by_key(|(index, _)| *index);
|
||||
enriched.into_iter().map(|(_, item)| item).collect()
|
||||
}
|
||||
|
||||
async fn fetch_detail(&self, item: VideoItem, mut requester: crate::util::requester::Requester) -> Result<VideoItem> {
|
||||
let html = requester
|
||||
.get(&item.url, None)
|
||||
.await
|
||||
.map_err(|error| ErrorKind::Parse(format!("detail request failed: {error}")))?;
|
||||
self.apply_detail_video(item, &html)
|
||||
}
|
||||
|
||||
async fn fetch_listing(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
page: u8,
|
||||
sort: &str,
|
||||
query: Option<&str>,
|
||||
options: ServerOptions,
|
||||
) -> Result<Vec<VideoItem>> {
|
||||
let (video_url, scope) = self.build_listing_request(page, sort, query);
|
||||
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 = requester_or_default(&options, CHANNEL_ID, "fetch_listing.requester");
|
||||
let text = match requester.get(&video_url, None).await {
|
||||
Ok(text) => text,
|
||||
Err(error) => {
|
||||
report_provider_error(
|
||||
CHANNEL_ID,
|
||||
"fetch_listing.request",
|
||||
&format!("url={video_url}; error={error}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
|
||||
let items = match self.parse_listing_page(&text, scope) {
|
||||
Ok(items) => items,
|
||||
Err(error) => {
|
||||
report_provider_error(
|
||||
CHANNEL_ID,
|
||||
"fetch_listing.parse",
|
||||
&format!("url={video_url}; error={error}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(old_items);
|
||||
}
|
||||
};
|
||||
|
||||
if items.is_empty() {
|
||||
return Ok(old_items);
|
||||
}
|
||||
|
||||
let items = self.enrich_listing_items(items, &options).await;
|
||||
cache.remove(&video_url);
|
||||
cache.insert(video_url, items.clone());
|
||||
Ok(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 sort = Self::normalize_sort(&sort).to_string();
|
||||
|
||||
match self
|
||||
.fetch_listing(cache, page, &sort, query.as_deref(), options)
|
||||
.await
|
||||
{
|
||||
Ok(items) => items,
|
||||
Err(error) => {
|
||||
report_provider_error(CHANNEL_ID, "get_videos", &error.to_string()).await;
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
||||
Some(self.build_channel(clientversion))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_creator_queries() {
|
||||
let target = PornhubProvider::parse_query_target("@channels:Brazzers")
|
||||
.expect("channel target should parse");
|
||||
assert!(matches!(target.kind, QueryTargetKind::Channel));
|
||||
assert_eq!(target.slug, "brazzers");
|
||||
|
||||
let target = PornhubProvider::parse_query_target("@pornstar:Alex Mack")
|
||||
.expect("pornstar target should parse");
|
||||
assert!(matches!(target.kind, QueryTargetKind::Pornstar));
|
||||
assert_eq!(target.slug, "alex-mack");
|
||||
|
||||
assert!(PornhubProvider::parse_query_target("teacher").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_browse_listing_cards() {
|
||||
let provider = PornhubProvider::new();
|
||||
let html = r#"
|
||||
<ul id="videoCategory" class="nf-videos videos search-video-thumbs">
|
||||
<li class="sniperModeEngaged"></li>
|
||||
<li class="pcVideoListItem js-pop videoblock videoBox withKebabMenu"
|
||||
data-video-id="466705435"
|
||||
data-video-vkey="67ed937c986b1">
|
||||
<a href="/view_video.php?viewkey=67ed937c986b1" title="Black asian teen"></a>
|
||||
<img src="https://example.com/thumb.jpg"
|
||||
data-mediabook="https://example.com/preview.webm" />
|
||||
<div class="marker-overlays"><var class="duration">12:18</var></div>
|
||||
<div class="videoUploaderBlock">
|
||||
<div class="usernameWrap">
|
||||
<a href="/model/honeycore">Honeycore</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="videoDetailsBlock">
|
||||
<span class="views"><var>199K</var> views</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
"#;
|
||||
|
||||
let items = provider
|
||||
.parse_listing_page(html, ListingScope::Browse)
|
||||
.expect("browse listing should parse");
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].id, "67ed937c986b1");
|
||||
assert_eq!(items[0].uploader.as_deref(), Some("Honeycore"));
|
||||
assert_eq!(
|
||||
items[0].uploaderUrl.as_deref(),
|
||||
Some("https://www.pornhub.com/model/honeycore")
|
||||
);
|
||||
assert_eq!(items[0].views, Some(199000));
|
||||
assert_eq!(items[0].duration, 738);
|
||||
assert_eq!(
|
||||
items[0].preview.as_deref(),
|
||||
Some("https://example.com/preview.webm")
|
||||
);
|
||||
assert!(items[0]
|
||||
.tags
|
||||
.as_ref()
|
||||
.is_some_and(|values| values.iter().any(|value| value == "@model:honeycore")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_detail_video_metadata() {
|
||||
let provider = PornhubProvider::new();
|
||||
let item = VideoItem::new(
|
||||
"69cfa159b1377".to_string(),
|
||||
"placeholder".to_string(),
|
||||
"https://www.pornhub.com/view_video.php?viewkey=69cfa159b1377".to_string(),
|
||||
CHANNEL_ID.to_string(),
|
||||
"https://example.com/thumb.jpg".to_string(),
|
||||
0,
|
||||
);
|
||||
let html = r#"
|
||||
<script>
|
||||
var flashvars_482929735 = {
|
||||
"video_title":"Brazzers Detail Title",
|
||||
"image_url":"https://example.com/detail.jpg",
|
||||
"video_duration":"930",
|
||||
"link_url":"https://www.pornhub.com/view_video.php?viewkey=69cfa159b1377",
|
||||
"mediaDefinitions":[
|
||||
{"format":"hls","quality":"240","videoUrl":"https://cdn.example.com/master-240.m3u8"},
|
||||
{"format":"hls","quality":"720","videoUrl":"https://cdn.example.com/master-720.m3u8"},
|
||||
{"format":"mp4","quality":"720","videoUrl":"https://cdn.example.com/video.mp4","remote":true}
|
||||
]
|
||||
};
|
||||
</script>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@type":"VideoObject",
|
||||
"thumbnailUrl":"https://example.com/ld-thumb.jpg",
|
||||
"uploadDate":"2026-04-03T00:00:00+00:00",
|
||||
"interactionCount":"5700",
|
||||
"author":{"name":"Brazzers"}
|
||||
}
|
||||
</script>
|
||||
<div class="categoriesWrapper">
|
||||
<a class="item" href="/categories/big-tits">Big Tits</a>
|
||||
</div>
|
||||
<div class="tagsWrapper">
|
||||
<a class="item" href="/video/search?search=maid">Maid</a>
|
||||
</div>
|
||||
"#;
|
||||
|
||||
let item = provider
|
||||
.apply_detail_video(item, html)
|
||||
.expect("detail page should enrich item");
|
||||
assert_eq!(item.title, "Brazzers Detail Title");
|
||||
assert_eq!(item.thumb, "https://example.com/ld-thumb.jpg");
|
||||
assert_eq!(item.duration, 930);
|
||||
assert_eq!(item.views, Some(5700));
|
||||
assert_eq!(item.uploader.as_deref(), Some("Brazzers"));
|
||||
assert!(item.uploadedAt.is_some());
|
||||
assert_eq!(item.formats.as_ref().map(|values| values.len()), Some(2));
|
||||
assert!(item.tags.as_ref().is_some_and(|values| values
|
||||
.iter()
|
||||
.any(|value| value == "Big Tits")));
|
||||
assert!(item.tags.as_ref().is_some_and(|values| values
|
||||
.iter()
|
||||
.any(|value| value == "Maid")));
|
||||
}
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
1377
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");
|
||||
}
|
||||
}
|
||||
1958
src/providers/supjav.rs
Normal file
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));
|
||||
}
|
||||
}
|
||||
1740
src/providers/vjav.rs
Normal file
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,
|
||||
}
|
||||
}
|
||||