Compare commits

...

509 Commits

Author SHA1 Message Date
Simon
e4d409fe1f javtiful fix 2026-05-02 14:11:06 +00:00
Simon
6a4fc98720 javtiful fix 2026-04-30 06:04:51 +00:00
Simon
698644c5f8 heavyfetish testing 2026-04-26 09:53:02 +00:00
Simon
4400f21b79 heqvyfetish fix for favs 2026-04-26 07:18:19 +00:00
Simon
cc66f045cd hqporner fix 2026-04-25 18:22:05 +00:00
Simon
2bd132820b viralxxxporn fix 2026-04-25 16:40:53 +00:00
Simon
635c45d2c1 javtiful fix 2026-04-25 15:57:24 +00:00
Simon
6a72f84c17 archivebate1 2026-04-22 14:01:08 +00:00
Simon
a47a69962f archivebate fix 2026-04-22 10:18:15 +00:00
Simon
47631b8a70 yesporn fix 2026-04-22 08:17:54 +00:00
Simon
c379550085 xxthots fix 2026-04-22 07:25:09 +00:00
Simon
e6eb85cd5a archivebate still needs work 2026-04-17 21:03:01 +00:00
Simon
33ec098aae heavyfetish fix 2026-04-17 13:35:21 +00:00
Simon
5ac6d72239 heavyfetish fix 2026-04-14 21:01:44 +00:00
Simon
57ae23656a pornxp fixes 2026-04-14 17:11:11 +00:00
Simon
fe5cf9a42f archivebate work and pornxp returned 2026-04-14 16:58:04 +00:00
Simon
765a21c110 porntrex fix 2026-04-12 11:52:06 +00:00
Simon
8846005995 pornxp return 2026-04-11 21:45:00 +00:00
Simon
61f39e23b7 porntrex 2026-04-11 21:43:36 +00:00
Simon
f85a725883 docs and prompt 2026-04-10 22:57:27 +00:00
Simon
57eb2d7063 upgrades 2026-04-09 07:19:33 +00:00
Simon
6e43b3b3d0 fixes etc 2026-04-07 16:53:45 +00:00
Simon
81e8158161 redtube fix 2026-04-07 12:32:41 +00:00
Simon
a7e38c97a6 header fix 2026-04-06 06:51:43 +00:00
Simon
70355dd969 pimpbunny thumb proxy 2026-04-06 06:40:31 +00:00
Simon
772835d4d1 vjav proxy 2026-04-06 06:23:34 +00:00
Simon
8d39b3a36f proxies 2026-04-05 21:27:47 +00:00
Simon
004399ecbe flaresolverr session cycling 2026-04-05 21:13:05 +00:00
Simon
bc2a73dd06 flaresolverr life cycle 2026-04-05 20:55:15 +00:00
Simon
7b464fe796 upgrades 2026-04-05 20:31:38 +00:00
Simon
9773590f64 small adjustment to ph thumb proxy 2026-04-05 16:18:53 +00:00
Simon
78e852c29d pornhub fix 2026-04-05 15:53:16 +00:00
Simon
4d50e0a9fb pornhub fix 2026-04-03 20:27:45 +00:00
Simon
b8c326306d vjav 2026-04-03 19:24:46 +00:00
Simon
041460d9b9 status update 2026-04-03 19:24:42 +00:00
Simon
c0717fdacf supjav tags-fixes 2026-04-03 18:17:14 +00:00
Simon
e680319541 pornhub 2026-04-03 18:01:03 +00:00
Simon
543e025dda supjav 2026-04-03 17:27:54 +00:00
Simon
067ff3d1da docs 2026-04-03 17:20:11 +00:00
Simon
4b017fafdf install dep 2026-04-01 21:38:28 +00:00
Simon
b07d269154 video from url 2026-04-01 12:21:14 +00:00
Simon
e2796bfd71 freejse searches 2026-03-31 23:01:51 +00:00
Simon
38acb2b5a5 freeuseporn 2026-03-31 16:39:27 +00:00
Simon
fb9098c689 omgxxx fix 2026-03-31 15:08:38 +00:00
Simon
c4be911d8b omgxx uploader 2026-03-31 14:40:31 +00:00
Simon
ef459fa6b5 noodlemagazine fix 2026-03-31 13:50:41 +00:00
Simon
bdc7d61121 uploaders 2026-03-31 13:39:11 +00:00
Simon
80207efa73 noodlemagazine upgrade 2026-03-31 13:09:51 +00:00
Simon
01831c70e7 sextb 2026-03-30 19:21:42 +00:00
Simon
429fb16fbd upgrades 2026-03-30 17:06:28 +00:00
Simon
4df2a672b7 yesporn fix 2026-03-30 06:59:38 +00:00
Simon
bc984a4791 spankbang fix 2026-03-30 02:53:31 +00:00
Simon
0df84a1fac fixed warnings 2026-03-30 02:34:26 +00:00
Simon
de8f88bf41 runtime test fixes 2026-03-30 02:21:53 +00:00
Simon
bbb1792dbe increase runtime test timout to 100s 2026-03-29 17:37:32 +00:00
Simon
e031396459 missav fix 2026-03-29 17:06:23 +00:00
Simon
4e95354880 javtiful fix 2026-03-29 17:05:32 +00:00
Simon
243d19cec0 runtime error handling 2026-03-29 16:24:49 +00:00
Simon
99fe4c947c shooshtime fix 2026-03-23 13:46:55 +00:00
Simon
90ce9c684b pornhd3x 2026-03-23 11:32:22 +00:00
Simon
9021521c00 fixes 2026-03-22 17:26:12 +00:00
Simon
fbe04fc752 upgrades 2026-03-22 15:56:25 +00:00
Simon
52f108da8e tiktok group 2026-03-22 12:46:30 +00:00
Simon
50ea0e73b7 pimpbunny fix 2026-03-22 12:27:46 +00:00
Simon
a2d31d90a1 more debug info 2026-03-21 22:29:45 +00:00
Simon
43594a6cfe hottub skill 2026-03-21 21:27:38 +00:00
Simon
7b66e5b28a debugging and single provider compime 2026-03-21 21:18:43 +00:00
Simon
05ea90405b globe for status 2026-03-21 20:21:43 +00:00
Simon
9bba981796 status changes 2026-03-21 19:29:30 +00:00
Simon
cecc1f994b status updated 2026-03-21 19:15:35 +00:00
Simon
75b7241803 hentaihaven fix 2026-03-21 17:53:07 +00:00
Simon
1b32df0c35 pimpbunny fix 2026-03-20 22:08:02 +00:00
Simon
259a07686d noodlemagazine fix 2026-03-20 21:05:18 +00:00
Simon
46cd348148 pimpbunny changes 2026-03-20 21:02:47 +00:00
Simon
dd7c4ec6a1 noodlemagazine thumb proxy 2026-03-20 13:52:06 +00:00
Simon
99e4a77507 no embed in video element 2026-03-19 19:04:49 +00:00
Simon
2b26019a66 vrporn 2026-03-18 22:54:51 +00:00
Simon
f88b789f25 yesporn 2026-03-18 21:48:05 +00:00
Simon
21ef0ebf17 hsex page >1 fix 2026-03-18 12:56:11 +00:00
Simon
ce1afd9873 status upgrade 2026-03-18 12:13:28 +00:00
Simon
ce781e2099 hsex 2026-03-18 11:22:48 +00:00
Simon
a66f44c747 heavyfetish and other changes 2026-03-17 21:04:11 +00:00
Simon
9ca9e820d9 remove embed 2026-03-17 09:58:48 +00:00
Simon
0563a7231a pimpbunny updates 2026-03-17 09:53:34 +00:00
Simon
3c3af70ed6 thumb updates 2026-03-17 09:44:38 +00:00
Simon
7680a93fab pimpbunny thumb 2026-03-17 09:17:28 +00:00
Simon
3a2e77436e swap to curl-cffi 2026-03-17 08:41:48 +00:00
Simon
9172941ac6 fixes 2026-03-17 01:12:52 +00:00
Simon
a977381b3b porndish fix 2026-03-17 00:57:50 +00:00
Simon
0d20fc7a7e docker update 2026-03-17 00:31:10 +00:00
Simon
0c11959d94 porndish 2026-03-17 00:24:29 +00:00
Simon
f8a09b0e97 normalize queries 2026-03-16 19:46:00 +00:00
Simon
9751c25b95 shooshtime 2026-03-16 19:37:05 +00:00
Simon
1f99eec5a3 fix 2 electric boogaloo 2026-03-16 00:16:07 +00:00
Simon
448efeff1e hanime thumbnail fix 2026-03-15 23:47:32 +00:00
Simon
0137313c6e porn4fans fix 2026-03-13 12:53:33 +00:00
Simon
6a62582c09 porn4fans fix 2026-03-13 12:13:04 +00:00
Simon
2e1223e519 fix? 2026-03-10 19:21:42 +00:00
Simon
96926563b8 dynamic base url 2026-03-10 18:45:32 +00:00
Simon
2ad131f38f noodlemagazine proxy implementation 2026-03-10 18:34:06 +00:00
Simon
efb1eb3c91 isLive implemented 2026-03-10 17:54:16 +00:00
Simon
967d1e8143 removed spankbang from archive 2026-03-10 17:48:45 +00:00
Simon
9d7146e705 updated wreq emulation 2026-03-10 16:15:45 +00:00
Simon
8b54eeac81 upgraded wreq 2026-03-10 16:07:48 +00:00
Simon
41373bf937 spankbang fix 2026-03-10 16:07:40 +00:00
Simon
c7866a1702 spankbang 2026-03-10 15:17:28 +00:00
Simon
b875086761 tokyomotion added 2026-03-10 08:46:19 +00:00
Simon
c57ce2e243 porn4fans done 2026-03-10 08:15:53 +00:00
Simon
2ed001801a fixed viralxxxporn 2026-03-10 07:53:18 +00:00
Simon
716b775105 format fixes 2026-03-10 07:53:07 +00:00
Simon
4c1815e0fc fixed warnings 2026-03-08 22:26:35 +00:00
Simon
9fea043888 fixed aspect ratio 2026-03-08 21:32:12 +00:00
Simon
1cb9c325b4 added ascpect_ratio to xfree 2026-03-08 21:31:30 +00:00
Simon
97046f1399 xfree fix 2026-03-08 21:23:18 +00:00
Simon
4c00336919 made burpsuite script executable 2026-03-08 20:54:01 +00:00
Simon
2f8951601b viralxxxporn and xfree bugfix 2026-03-05 19:49:30 +00:00
Simon
63782f6a7c xfree and beeg bug fix 2026-03-05 19:34:55 +00:00
Simon
5be0a89e51 pmvhaven fix 2026-03-05 18:59:10 +00:00
Simon
2627505ade fixes and cleanup 2026-03-05 18:18:48 +00:00
Simon
76fd5a4f4f bugfixes 2026-03-05 13:51:57 +00:00
Simon
8157e223fe provider refactors and fixes 2026-03-05 13:28:38 +00:00
Simon
060d8e7937 bug prevention with video.url for Hottub38 2026-02-26 11:05:32 +00:00
Simon
4ad9453245 fixed the clientversion parsing 2026-02-26 10:52:03 +00:00
Simon
b3d10ae0d9 disabled pornxp 2026-02-26 10:24:28 +00:00
Simon
b8c52e059d archived tube8 until its ready 2026-02-25 06:48:28 +00:00
Simon
ce13162a5f javtiful bugfix 2026-02-25 06:39:22 +00:00
Simon
ff8d1afef6 remove tube8 until yt-dlp is ready 2026-02-23 18:02:47 +00:00
Simon
718e7c3f78 tube8 2026-02-23 17:34:33 +00:00
Simon
61840b7ec5 updated .gitignore 2026-02-23 11:52:41 +00:00
Simon
08e94b5240 set aspect_ratio to 0.715 2026-02-23 11:52:15 +00:00
Simon
dfbcf85ddf hentaihaven: fixed a bug that would not get pages >1 2026-02-23 11:52:15 +00:00
Simon
36c482a615 bug-workaround, set media-url as normal url 2026-02-23 11:52:15 +00:00
Simon
4d29d19c0a add episode title as format_note 2026-02-23 11:52:15 +00:00
Simon
00c8c99f09 added headers 2026-02-23 11:52:15 +00:00
Simon
becfd52e17 hentaihaven 2026-02-23 11:52:15 +00:00
Simon
3fe6280f27 freshporno was removed 2026-02-23 11:52:15 +00:00
Simon
5105d33212 more pimpbunny fixes 2026-02-23 11:52:15 +00:00
Simon
1bed8c56a0 disabled sccache 2026-02-23 11:52:15 +00:00
Simon
18b4afddcc removed debug print 2026-02-23 11:52:15 +00:00
Simon
27b87d52d5 pimpbunny fixed 2026-02-23 11:52:15 +00:00
Simon
b3256a741e chaturbate bugfixes 2026-02-23 11:52:15 +00:00
Simon
4860d6abff chaturbate bugfix, cut too much away 2026-02-23 11:52:15 +00:00
Simon
9964c11a8a chaturbate 2026-02-23 11:52:15 +00:00
Simon
eea8d9ae6f some upgrades 2026-02-23 11:52:15 +00:00
Simon
b45687d578 config improvements 2026-02-23 11:52:10 +00:00
Simon
f4fbd62c97 bugfix with tag queries 2026-02-10 20:30:58 +00:00
Simon
2f1fd8f33a freepornvideosxxx 2026-02-10 20:24:10 +00:00
Simon
5b9ef5b279 decomissiond hottub version 2026-02-10 18:47:55 +00:00
Simon
44cfb1f208 hanime work in progress... 2026-02-08 19:38:03 +00:00
Simon
310dfd71e9 accept int or string in api 2026-02-08 15:42:58 +00:00
Simon
7b1bb758e3 tnaflix upgrade 2026-02-08 14:26:18 +00:00
Simon
bf622d95a6 perverzija bugfix 2026-02-08 09:05:03 +00:00
Simon
360b615742 bugfixes 2026-02-08 08:49:19 +00:00
Simon
5a08d2afe7 repeat a request if it fails initially 2026-01-21 11:32:02 +00:00
Simon
5224a2eb47 improved Error resistance 2026-01-21 11:24:03 +00:00
Simon
e7fb0ed723 adapted to new layout 2026-01-16 08:54:37 +00:00
Simon
6a7bc68849 improved all provider 2026-01-15 19:17:46 +00:00
Simon
27e2bcdbba fixes 2026-01-15 19:04:28 +00:00
Simon
182eb8ac01 less printing 2026-01-14 15:42:44 +00:00
Simon
e2f3bc2ecb bugfixes 2026-01-14 15:41:22 +00:00
Simon
4f9c7835bf added url to error log 2026-01-14 14:17:00 +00:00
Simon
87b9d20240 some more debugging 2026-01-14 14:15:26 +00:00
Simon
708560d2e8 removed prints 2026-01-14 11:50:15 +00:00
Simon
cacd45d893 upgrades 2026-01-14 11:49:27 +00:00
Simon
602dbe50f0 bugfixes 2026-01-14 11:30:32 +00:00
Simon
cce6104df3 title bugfix 2026-01-13 21:40:51 +00:00
Simon
34992242b7 various bugfixes 2026-01-13 18:13:51 +00:00
Simon
aaff7d00c6 hypnotube 2026-01-10 18:29:29 +00:00
Simon
eb49998593 fixed a bug where the url was wrongly formatted 2026-01-07 14:39:24 +00:00
Simon
cf04441a69 javtiful proxy 2026-01-07 14:24:18 +00:00
Simon
6fac9d6d45 corrected status "cacheDuration" to 1800 2026-01-07 13:18:51 +00:00
Simon
2edb12a024 corrected url for search queries 2026-01-07 13:17:22 +00:00
Simon
7f3ae83b1b more bugfixes 2026-01-07 13:09:05 +00:00
Simon
0b3f1fdc1d macro fix 2026-01-07 13:06:36 +00:00
Simon
792e246121 bugfix 2026-01-07 13:06:15 +00:00
Simon
0fc3bed6a7 javtiful done 2026-01-07 12:48:38 +00:00
Simon
c0368b2876 bugfixes 2026-01-03 23:51:19 +00:00
Simon
4a7528c516 bugfixes 2026-01-03 10:17:39 +00:00
Simon
97eeccf2bd more fixes 2026-01-02 15:32:07 +00:00
Simon
5ab2afa967 omgxxx bugfix 2026-01-02 15:11:27 +00:00
Simon
262b908692 more fixes 2026-01-02 14:58:29 +00:00
Simon
89eecbe790 bugfixes 2026-01-02 14:55:13 +00:00
Simon
27bb3daec4 more blacklisting 2025-12-27 10:25:00 +00:00
Simon
f1eb3c236b typo 2025-12-27 10:20:58 +00:00
Simon
e7854ac1ac bugfixes 2025-12-27 10:17:23 +00:00
Simon
ca67eff142 bugfix 2025-12-25 22:53:27 +00:00
Simon
0e347234b3 bugfixes 2025-12-25 07:07:14 +00:00
Simon
11c8c1a48f ignore doodstream.com 2025-12-22 12:25:09 +00:00
Simon
6536fb13b3 better tag system 2025-12-11 11:58:11 +00:00
Simon
9789afb12b max size of 100k for fast cache 2025-12-08 07:12:20 +00:00
Simon
b986faa1d4 healthcheck, logging and ulimit adjustment 2025-12-05 09:11:59 +00:00
Simon
7124b388fa cleanup 2025-12-05 09:09:47 +00:00
Simon
632931f515 search bugfix 2025-12-04 20:12:57 +00:00
Simon
9739560c03 removed unimportant prints 2025-12-04 13:51:34 +00:00
Simon
80d874a004 query bug fix 2025-12-04 13:37:24 +00:00
Simon
64dc7455ee http version 2 2025-12-04 13:27:16 +00:00
Simon
9e30eedc77 run init load in its own thread 2025-12-04 13:11:46 +00:00
Simon
75e28608bd missav bugfixes 2025-12-04 11:54:31 +00:00
Simon
e22a3f2d6d prevent empty tags/formats 2025-12-01 16:07:45 +00:00
Simon
07b812be64 pimpbunny 2025-11-30 14:15:09 +00:00
Simon
61e38caed5 fixed wrong order of format/quality 2025-11-30 07:05:49 +00:00
Simon
e5a6c8decc reverse formats order so high quality is selected first 2025-11-30 07:03:42 +00:00
Simon
d856ade32b adjusted requester to supply http::version itself 2025-11-30 06:53:21 +00:00
Simon
2de6a7d42b testing found 2025-11-29 20:14:59 +00:00
Simon
39e38249b7 noodlemagazine 2025-11-29 20:08:46 +00:00
Simon
e924c89573 undo 2025-11-29 18:52:48 +00:00
Simon
3f57569511 htmlencode videourl 2025-11-29 18:46:28 +00:00
Simon
23190ee05c bugfix 2025-11-29 17:22:41 +00:00
Simon
12053ce6db removed debug print 2025-11-29 17:21:04 +00:00
Simon
5522f2e37d pmvhaven backend fix 2025-11-29 17:16:21 +00:00
Simon
8f885c79d4 send categories in channel info 2025-11-29 15:56:22 +00:00
Simon
d7e7f70bd2 bugfixes 2025-11-29 14:20:36 +00:00
Simon
0e02a1b821 tags upgrade 2025-11-29 13:55:56 +00:00
Simon
cafb990fd4 removed debug prints 2025-11-29 08:24:14 +00:00
Simon
53ac33f856 hqporner 2025-11-29 08:20:38 +00:00
Simon
ef57172fdd omg.xxx changed some html layouts 2025-11-29 08:20:34 +00:00
Simon
f91f06c45e set cache duration 2025-11-28 14:11:24 +00:00
Simon
ee6919315b remove "New" from title 2025-11-28 14:07:32 +00:00
Simon
b4b57ccfc7 sxyprn bugfix for query 2025-11-28 13:43:48 +00:00
Simon
36e549b176 regex date checking 2025-11-15 12:41:05 +00:00
Simon
85c270b906 more bugfixes 2025-11-15 12:35:53 +00:00
Simon
14671d6842 xxdbx bugfix 2025-11-15 12:33:26 +00:00
Simon
a875cec9f6 tags fixed 2025-11-14 09:43:20 +00:00
Simon
8d4a357edf removed debug print 2025-11-14 09:40:52 +00:00
Simon
474a4b7f38 xxdbx 2025-11-14 09:39:29 +00:00
Simon
35cd6a440f use video url as preview if duration <=2min 2025-11-13 17:21:50 +00:00
Simon
d9b505e516 dont start burpsuite and vnc if PROXY!=1 2025-11-13 08:44:22 +00:00
Simon
2d719ad2d7 rule34gen 2025-11-09 14:18:52 +00:00
Simon
4d2470e028 thumbnail bugfix 2025-10-25 15:13:39 +00:00
Simon
e79fd15b91 bugfix 2025-10-25 15:07:20 +00:00
Simon
f8d382568b omgxxx print removed 2025-10-25 15:04:55 +00:00
Simon
43c22846c5 pronxp bugfix 2025-10-25 15:02:52 +00:00
Simon
6c542ce6b4 pornxp 2025-10-25 14:53:18 +00:00
Simon
d6b1f5d93f status bugfix 2025-10-23 18:33:21 +00:00
Simon
df01dc36f7 status update 2025-10-23 18:30:11 +00:00
Simon
629000ba37 tnaflix 2025-10-23 18:25:28 +00:00
Simon
d864bc8a4e hanime bugfix 2025-10-17 08:31:05 +00:00
Simon
a0e0a8e4b1 hanime updates 2025-10-17 08:19:32 +00:00
Simon
09c06df163 pmvhaven fix 2025-10-12 19:23:16 +00:00
Simon
dcb5148da6 beeg bugfix 2025-10-09 20:27:45 +00:00
Simon
7dd58ebfc4 beeg bugfixes 2025-10-09 20:17:48 +00:00
Simon
3c2eba8658 beeg 2025-10-09 20:07:14 +00:00
Simon
12af9a89cd omgxxx bugfix 2025-10-07 19:53:08 +00:00
Simon
8a9baa1552 bugfix 2025-10-07 10:48:35 +00:00
Simon
d4b96a70ee sites in tags 2025-10-07 10:48:01 +00:00
Simon
ef4a86d3ca bugfix 2025-10-04 19:57:03 +00:00
Simon
68c5f4971c apply stars 2025-10-04 18:54:35 +00:00
Simon
77f6d27f5a only top ten sites 2025-10-04 18:50:55 +00:00
Simon
d930958081 only top 100 sites of pornstars 2025-10-04 18:38:05 +00:00
Simon
8dd46954d6 status update 2025-10-04 18:26:52 +00:00
Simon
0662512ebf load stars fix 2025-10-04 18:19:44 +00:00
Simon
b2a07b0392 now with pornstars fetch 2025-10-04 18:12:23 +00:00
Simon
499e528697 sites bugfix 2025-10-04 17:57:19 +00:00
Simon
a6be0f33ef sites applied 2025-10-04 17:45:27 +00:00
Simon
983e861a63 removed proxy from requester 2025-10-04 17:40:27 +00:00
Simon
7c73601954 now supports sites 2025-10-04 17:40:03 +00:00
Simon
43a2d09a55 omg update 2025-10-04 16:57:23 +00:00
Simon
67e7b96758 updates, enables networks on omgxxx 2025-10-04 16:49:52 +00:00
Simon
efedc0e6e4 slimmed omgxxx 2025-10-04 15:41:43 +00:00
Simon
ef625527a2 dynamic network loading for omgxxx 2025-10-04 15:41:24 +00:00
Simon
28a4c57616 overhault to fix warnings etc 2025-10-04 14:28:29 +00:00
Simon
d84cc715a8 omgxxx bugfixes 2025-10-04 09:39:40 +00:00
Simon
5b2a7430bc omgxxx bugfix 2025-10-03 18:01:57 +00:00
Simon
81b967e811 omgxxx bugfix 2025-10-03 17:47:26 +00:00
Simon
f9ccdd8b33 handled some warnings 2025-10-03 17:34:16 +00:00
Simon
20d069f01f omgxxx 2025-10-03 17:25:47 +00:00
Simon
37d11034d8 pornzog 2025-10-01 19:28:41 +00:00
Simon
29aa6fc007 removed formats 2025-10-01 10:42:11 +00:00
Simon
259106fa13 removed protocol from sxyprn stream format 2025-10-01 10:28:08 +00:00
Simon
23f6571911 updates to sxyprn 2025-10-01 08:14:46 +00:00
Simon
8e6f115871 sxyprn bugfix time and preview 2025-10-01 06:25:31 +00:00
Simon
53737784b7 how2update 2025-09-29 15:15:34 +00:00
Simon
154e3a149e nano docker compose 2025-09-29 15:12:23 +00:00
Simon
611c8a99e7 replace marker 2025-09-29 15:10:34 +00:00
Simon
92e43d2449 cargo build instruction 2025-09-29 15:09:33 +00:00
Simon
4be7ccc6e1 setup instructions 2025-09-29 15:07:31 +00:00
Simon
39acd8ef96 domain safe 2025-09-29 15:01:25 +00:00
Simon
661a28b6ac aspectratio typo 2025-09-26 16:08:41 +00:00
Simon
3f98a9eecb bugfixes 2025-09-19 19:05:58 +00:00
Simon
3e4f5526b0 rev 2025-09-19 19:04:04 +00:00
Simon
4d80b827e1 reverse formats 2025-09-19 19:02:46 +00:00
Simon
b75a2cc298 bugfix 2025-09-19 18:59:02 +00:00
Simon
f12f50e787 bugfix 2025-09-19 18:53:50 +00:00
Simon
d9fed99104 all urls for paradise hill 2025-09-19 18:33:18 +00:00
Simon
025ee713e3 error fix 2025-09-19 15:48:51 +00:00
Simon
913472ebfb adaptions format for paradise hill 2025-09-19 11:39:34 +00:00
Simon
584abfd431 cargo fixed 2025-09-19 11:13:37 +00:00
Simon
1b4bc6cb13 paradise hill 2025-09-19 11:12:26 +00:00
Simon
8effce7c2b remove skip 2025-09-13 08:06:18 +00:00
Simon
428307f52d skip non-working videos 2025-09-13 07:39:56 +00:00
Simon
5e5838debf youjizz 2025-09-13 07:26:55 +00:00
Simon
a096ec66f2 testing 2025-09-13 06:03:00 +00:00
Simon
c17590ccb3 adapted cache duration 2025-09-09 05:33:12 +00:00
Simon
436e33d015 client gate for sxyprn 2025-09-05 05:38:18 +00:00
Simon
8a57d0c2bf cacheDuration 2025-09-03 14:50:45 +00:00
Simon
c7e67a3cba fixes? 2025-09-03 12:33:21 +00:00
Simon
31adceb3e9 sxyprn status page 2025-09-03 12:24:24 +00:00
Simon
edb23b62ba fix duration 2025-09-03 12:20:05 +00:00
Simon
ff18f3eb34 sxyprn 2025-09-03 12:15:08 +00:00
Simon
c3f994ccbb freshporno 2025-09-02 09:58:19 +00:00
Simon
9caec79427 organized removed providers 2025-08-31 17:51:53 +00:00
Simon
7d514895cd exclude noodlemagazine until impersonate runs 2025-08-31 17:48:02 +00:00
Simon
8f5fc41bd2 bugfixes 2025-08-31 17:22:51 +00:00
Simon
437deb388b noodlemagazine 2025-08-31 17:16:37 +00:00
Simon
23a643b9dc final? 2025-08-29 20:26:03 +00:00
Simon
6434939a69 more fixes 2025-08-29 20:22:57 +00:00
Simon
4f1b58d583 bugfix for search 2025-08-29 20:17:48 +00:00
Simon
bb5f610c60 bugfixes 2025-08-29 20:01:29 +00:00
Simon
c673a1c22b hentaimoon is currently broken 2025-08-29 19:46:02 +00:00
Simon
e7b10cbe4f hotfix 2025-08-29 19:44:26 +00:00
Simon
53a4c62bfe porn00 2025-08-29 19:36:33 +00:00
Simon
44b42170be testing this fix 2025-08-28 17:53:02 +00:00
Simon
f10491dd73 bugfix 2025-08-23 06:50:21 +00:00
Simon
09adedae72 enabled proxy for requester 2025-08-21 10:21:16 +00:00
Simon
2a32690894 removed debug prints 2025-08-21 10:18:36 +00:00
Simon
59d30695e9 now safes the cookies for the requester 2025-08-21 10:18:06 +00:00
Simon
c05991ee23 fixes for perfectgirls and missav 2025-08-21 09:06:30 +00:00
Simon
61aa6a966e xxthots 2025-08-20 15:06:58 +00:00
Simon
24e4c5dfd7 cargo auto fix 2025-08-20 14:02:51 +00:00
Simon
c135f60894 missav bugfix 2025-08-20 14:00:34 +00:00
Simon
746147c7c0 id bugfix 2025-08-20 13:48:30 +00:00
Simon
812d1c205f update a color 2025-08-20 13:44:01 +00:00
Simon
79b833b857 missav upgrade 2025-08-20 13:40:16 +00:00
Simon
87965d4659 missav status page 2025-08-20 12:03:37 +00:00
Simon
c0d8b8b2f4 commented out proxies 2025-08-20 12:00:22 +00:00
Simon
0ba1c62daa missav 2025-08-20 11:59:55 +00:00
Simon
6dd63ae620 bugfixes 2025-08-19 10:53:38 +00:00
Simon
fef5ee5796 bugfixes, md5 id 2025-08-19 10:50:12 +00:00
Simon
07281e8360 bug fix 2025-08-19 10:46:20 +00:00
Simon
ee8abaed8d removed debug print 2025-08-19 10:45:03 +00:00
Simon
d01436ab6a adapted proxies 2025-08-19 10:43:41 +00:00
Simon
caed5088f5 hentai moon 2025-08-19 10:41:21 +00:00
Simon
b383a36077 commented out proxies 2025-08-18 10:07:27 +00:00
Simon
0f2983ca15 delete hentaihavem (not possible) + bugfix on rule34video 2025-08-18 10:03:29 +00:00
Simon
f7a836c353 testing 2025-08-16 19:03:25 +00:00
Simon
e80eb79613 bugfix 2025-08-16 18:57:14 +00:00
Simon
750be251c0 updates 2025-08-16 18:56:19 +00:00
Simon
49ca76ab48 updates 2025-08-16 18:46:10 +00:00
Simon
2248d11d3e api fix for hentaihaven 2025-08-15 19:50:51 +00:00
Simon
5dcc046005 title bugfix 2025-08-15 19:48:54 +00:00
Simon
9f4e8eeff0 hentaihaven 2025-08-15 19:48:15 +00:00
Simon
7c645bf653 bugfix with thumbs 2025-08-15 19:03:15 +00:00
Simon
60e3db9a8e removed prints 2025-08-15 18:59:45 +00:00
Simon
7185d89a64 bugfixes 2025-08-15 18:56:19 +00:00
Simon
8add6f44aa removed proxies 2025-08-15 18:45:52 +00:00
Simon
88f1126ec5 homoxxx 2025-08-15 18:42:37 +00:00
Simon
7d8f0d1b4f okxxx 2025-08-15 18:22:04 +00:00
Simon
8017263d21 perfectgirls 2025-08-14 19:16:17 +00:00
Simon
0a1516b82a updated status page 2025-08-14 19:00:35 +00:00
Simon
58871d8db9 updated api for automatically all sites 2025-08-14 18:54:05 +00:00
Simon
e67025e104 views and tags on pornhat 2025-08-14 18:38:23 +00:00
Simon
ca44f08393 pornhat 2025-08-14 18:32:50 +00:00
Simon
5b544dbbf6 sort upgrade for okporn 2025-08-10 15:41:57 +00:00
Simon
102fc37683 duration bugfix 2025-08-10 15:38:37 +00:00
Simon
944746bf12 ok.porn 2025-08-10 15:32:41 +00:00
Simon
673458b630 pornhub bugfix #2 2025-08-10 15:17:55 +00:00
Simon
6405596fb8 pornhub bugfix 2025-08-10 15:15:24 +00:00
Simon
97066a184a all provider 2025-08-10 14:02:09 +00:00
Simon
8944646c85 bugfixes 2025-08-10 12:53:00 +00:00
Simon
0aee46371a testing 2025-08-10 12:50:01 +00:00
Simon
0ce2347022 added wip 2025-08-09 12:23:08 +00:00
Simon
3feeb02251 testing and fixing 2025-08-09 12:21:43 +00:00
Simon
6b4b0be522 removed debug logs 2025-08-09 11:30:49 +00:00
Simon
bdc26c8b81 title fix 2025-08-09 11:30:26 +00:00
Simon
e7998f8e19 fixed queried thumbnails 2025-08-09 11:26:58 +00:00
Simon
4aba459f04 redtube 2025-08-09 11:17:58 +00:00
Simon
b6f6212de0 bugfix 2025-08-03 18:56:07 +00:00
Simon
5dd92b21c4 bugfix 2025-08-03 18:49:38 +00:00
Simon
37c534f257 updated perverzija 2025-08-03 18:36:32 +00:00
Simon
bbd4f975eb studio and stars tags for perverzija 2025-08-03 17:30:55 +00:00
Simon
62f467ca68 fixed bug on rule34video 2025-08-03 16:25:45 +00:00
Simon
32eb704548 bugfix pornhub 2025-08-01 18:48:42 +00:00
Simon
d1a4975aa3 removed proxies 2025-08-01 14:42:04 +00:00
Simon
faa2cea37e reenable proxy 2025-08-01 14:33:17 +00:00
Simon
57ed44c2d4 hotfix 2025-08-01 14:30:55 +00:00
Simon
f1a3046f62 removed proxy from pornhub 2025-08-01 14:02:06 +00:00
Simon
e18e4da559 rul34video 2025-07-20 09:10:07 +00:00
Simon
2d1def2dfe bugfix 2025-07-20 07:53:08 +00:00
Simon
859ccd5efb filter/sort for pmvhaven 2025-07-20 07:50:10 +00:00
Simon
323fbfd5c9 adapted pmvhaven 2025-07-20 05:14:59 +00:00
Simon
5f084970d2 bugfix 2025-07-19 16:02:33 +00:00
Simon
053575f2c3 undo preview 2025-07-19 15:55:37 +00:00
Simon
f88129ff39 added preview to pornhub 2025-07-19 15:53:37 +00:00
Simon
441780f29b more bugfixes 2025-07-19 15:38:08 +00:00
Simon
7d933384c4 bugfix 2025-07-19 15:31:43 +00:00
Simon
bbbb8f5fdf hotfix 2025-07-19 15:22:48 +00:00
Simon
5806f5ee2b channels fix 2025-07-19 15:21:59 +00:00
Simon
44620a88d5 bugfix 2025-07-19 15:10:09 +00:00
Simon
624ee7d782 forward 2025-07-19 15:07:30 +00:00
Simon
9102a9f43f pornhub update 2025-07-19 15:02:07 +00:00
Simon
519f178dea bugfix 2025-07-19 14:44:56 +00:00
Simon
8a477bffc9 hotfix 2025-07-19 14:43:57 +00:00
Simon
41374470b1 advanced search for channel and models 2025-07-19 14:37:11 +00:00
Simon
6ef74955cf back to including both urls 2025-07-19 08:23:27 +00:00
Simon
eafd557d09 bugfixes 2025-07-19 08:10:55 +00:00
Simon
83fe467252 bugfixes? 2025-07-18 18:55:42 +00:00
Simon
3998c8b1a9 trying format fix 2025-07-18 18:07:02 +00:00
Simon
4c1776bbcb formats on pmvhaven 2025-07-18 18:05:41 +00:00
Simon
31a31f5733 hotfix 2025-07-18 17:09:11 +00:00
Simon
28db17a363 hotfix pmvhaven 2025-07-18 16:54:23 +00:00
Simon
90f85dc6e8 pmvhaven category option 2025-07-18 10:02:54 +00:00
Simon
0b2e1478ea bugfix 2025-07-18 09:43:25 +00:00
Simon
13c36a4328 hotfix 2025-07-18 04:02:32 +00:00
Simon
b4ee574433 pmvhaven 2025-07-17 18:41:57 +00:00
Simon
9d3d8ce67b more bugfixes for viewcount 2025-07-16 19:13:16 +00:00
Simon
19a6115eb1 views and bug fixes 2025-07-16 19:00:42 +00:00
Simon
19146616dc bugfixes again 2025-07-16 18:50:33 +00:00
Simon
9e1a2a65c9 more bugfixes 2025-07-16 18:48:00 +00:00
Simon
7008e38838 bugfix 2025-07-16 18:00:33 +00:00
Simon
ae527041ae implemented pornhub 2025-07-16 17:51:41 +00:00
Simon
0a60d12525 removed proxy for debug 2025-07-16 13:52:58 +00:00
Simon
bd565e044a fixed bug with spankbang where only 7 video items where shown 2025-07-16 13:49:20 +00:00
Simon
a63e260dac removed delay 2025-07-15 19:06:43 +00:00
Simon
f81a0e2ec5 some logging 2025-07-15 18:52:10 +00:00
Simon
bed8882329 reduced warnings 2025-07-15 18:45:23 +00:00
Simon
d77e292dbd switched request module, so no need for burpsuite anymore 2025-07-15 18:01:26 +00:00
Simon
fe8c564126 removed spam print 2025-07-13 14:05:01 +00:00
Simon
2c38a2fa6e fix 2025-07-13 13:44:28 +00:00
Simon
853a24f9cd removed preview since its currently unnecessary 2025-07-13 13:43:24 +00:00
Simon
4c5e5028da supervisord test 2025-07-13 13:38:32 +00:00
Simon
0ebfd6cf10 removed unnecessary prints 2025-07-13 13:18:17 +00:00
Simon
465d1fc99c fixed bug 2025-07-13 13:17:32 +00:00
Simon
93e090c050 fix sleep in spankbang 2025-07-13 12:48:36 +00:00
Simon
0d3e0170d4 hotfix 2025-07-13 12:46:49 +00:00
Simon
6df8b3e857 ntex sleep to not block threads 2025-07-13 12:46:14 +00:00
Simon
1d8b79cb76 multithreading 2025-07-13 12:43:58 +00:00
Simon
68c566caa7 edited api status 2025-07-13 11:02:47 +00:00
Simon
fe542b970d bugfixes 2025-07-13 10:48:55 +00:00
Simon
3f391a4516 spankbang now working with DB 2025-07-13 10:14:42 +00:00
Simon
9cf532e831 testing spankbang 2025-07-13 09:33:36 +00:00
Simon
b7a3daebe3 removed fapello from /api/status 2025-07-11 15:40:07 +00:00
Simon
97617735e4 update burp script to draw less rescourses 2025-07-06 13:46:43 +00:00
Simon
3c9c9c8cd3 commented out erothots 2025-06-19 11:57:08 +00:00
Simon
d663b344aa hotfix 2025-06-19 11:54:20 +00:00
Simon
e1735657f0 hot -> new 2025-06-19 11:47:55 +00:00
Simon
0a5adac63a hotfix final 2025-06-19 11:46:48 +00:00
Simon
b94fca9986 dont know... 2025-06-19 11:34:46 +00:00
Simon
026266dd83 hotfix fix 2025-06-19 11:20:17 +00:00
Simon
242ce91525 hotfix burp script 2025-06-19 11:18:15 +00:00
Simon
23f6df62f0 "failsave" burp script 2025-06-19 11:15:56 +00:00
Simon
6405cbb269 add erothots 2025-06-19 11:12:39 +00:00
Simon
f8fe0aa1ec added sudo to docker image 2025-06-15 10:12:12 +00:00
Simon
842db68c57 fix "hasNextPage" 2025-06-15 08:48:13 +00:00
Simon
c34d6dcc14 background loading 2025-06-15 07:29:39 +00:00
Simon
8cd404d6b1 client version check on api 2025-06-10 08:42:16 +00:00
Simon
2a912a4010 temporarily disabled format 2025-06-09 18:22:34 +00:00
Simon
9bec5e4b60 hotfix 2025-06-09 18:03:42 +00:00
Simon
0405d2a5ce hotfix video id bug 2025-06-09 18:02:50 +00:00
Simon
15c8a93990 cleanup 2025-06-09 15:22:17 +00:00
Simon
727ceaef4b whoopsie 2025-06-09 13:34:25 +00:00
Simon
5f4c12e2ff fixed bug with parsing views and rating 2025-06-09 13:31:59 +00:00
Simon
a7a107c9b4 removed debug print 2025-06-09 13:24:23 +00:00
Simon
00b45ecaf9 removed unused imports 2025-06-09 13:21:26 +00:00
Simon
b8423f6731 bug hotfix 2025-06-09 13:20:37 +00:00
Simon
61cf3f625e removed unnecessary fn from provider 2025-06-09 13:10:25 +00:00
Simon
673d9aad5b implemented spankbang 2025-06-09 13:08:26 +00:00
Simon
0496954f41 surpress supervisor crit warnings 2025-06-08 08:23:51 +00:00
Simon
578ac3e034 added sqlitebrowser 2025-06-08 06:49:37 +00:00
Simon
f4f22572c1 default ordering 2025-06-08 06:45:47 +00:00
Simon
e87a2ed237 hot fix bug 2025-06-06 16:14:15 +00:00
Simon
95eeb273f5 cleanup 2025-06-06 09:19:40 +00:00
Simon
69301f1e97 fixed hanime sort options 2025-06-06 08:53:14 +00:00
Simon
ec1d7b8eef cleanup and fixed faulty perverzija urls 2025-06-06 08:51:24 +00:00
Simon
60a07269f6 clean cache, handled warnings etc 2025-06-06 07:48:21 +00:00
Simon
df323ec9fd sorting for hanime 2025-06-05 19:59:28 +00:00
Simon
175c9b748f database support 2025-06-05 18:50:28 +00:00
Simon
6d08362937 database support 2025-06-05 18:50:26 +00:00
Simon
52081698e9 fixed hanime search 2025-06-05 04:22:48 +00:00
Simon
d837028faf sort burp view 2025-06-04 18:52:35 +00:00
Simon
cb03417f5f removed the all channel 2025-06-04 18:47:27 +00:00
Simon
d7fc427696 implemented hanime 2025-06-04 18:33:49 +00:00
Simon
3150e57411 caching 2025-06-04 07:35:55 +00:00
Simon
8d5da3a4dc hotfix 2025-06-03 19:26:26 +00:00
Simon
2ddc5e86e2 hotfix 2025-06-03 18:10:08 +00:00
Simon
2e8b8bea0c implemented tags for videos 2025-06-03 15:34:02 +00:00
Simon
082b3b5c1d fixed query 2025-06-03 13:46:54 +00:00
Simon
a7610e1bb3 cleanup and fixed query 2025-06-03 12:59:31 +00:00
Simon
261c81e391 cleanup and fixing 2025-06-03 12:29:41 +00:00
Simon
1324d58f50 docker-compose 2025-06-03 11:55:10 +00:00
Simon
9399949c36 renamed var 2025-06-03 11:52:27 +00:00
Simon
03e4554131 increased wait time and activated burpsuite for supervisord 2025-06-03 10:44:34 +00:00
Simon
c218828d40 hotfix 2025-06-03 10:40:15 +00:00
Simon
15c5216309 simplified and unsecure ;) 2025-06-03 10:39:28 +00:00
Simon
58cff87274 hotfix 2025-06-03 10:38:16 +00:00
Simon
e51de99853 clear tmp for burp 2025-06-03 10:37:21 +00:00
Simon
6b1746180f hotfix path 2025-06-03 10:33:58 +00:00
Simon
08d7b09e05 update start script 2025-06-03 10:31:19 +00:00
Simon
d74b7b97e6 added jar path 2025-06-03 10:28:03 +00:00
Simon
d1b23dd293 added missing import 2025-06-03 10:26:22 +00:00
Simon
0f9c23168c burp start script 2025-06-03 10:24:22 +00:00
Simon
4cd9661d4b fixed path 2025-06-03 10:09:27 +00:00
Simon
91afe6e48f gnome screenshot for autoburp 2025-06-03 09:58:18 +00:00
Simon
ae312a83fb added start_burp.sh 2025-06-03 09:54:25 +00:00
Simon
4cf29ce201 typo 2025-06-03 09:47:56 +00:00
Simon
8da7b30c07 Dockerfile hotfix 2025-06-03 08:37:01 +00:00
Simon
cae15e7636 auto burp part 1 2025-06-03 08:28:34 +00:00
Simon
d2254128d7 java update 2025-06-03 07:43:31 +00:00
Simon
be83e12bc3 hotfix hottub path 2025-06-03 07:28:23 +00:00
Simon
babaf90762 hotfix hottub supervisord 2025-06-03 07:26:33 +00:00
Simon
860eadcbd4 supervisor and other update 2025-06-03 07:08:35 +00:00
Simon
ae8fd8e922 MOCK API for tests 2025-06-01 18:09:20 +00:00
Simon
918ed1a125 flaresolverr for loading behind cloudflare 2025-06-01 11:16:26 +00:00
Simon
edc7879324 removed proxy 2025-05-31 13:58:46 +00:00
Simon
580751af03 implemented query and flaresolverr 2025-05-31 13:54:27 +00:00
Simon
3fe699b62d removed openssl from ntex cargo.toml 2025-05-31 09:55:34 +00:00
Simon
0cb3531ae4 removed default env logger 2025-05-31 09:53:14 +00:00
Simon
5b9a1b351c more cleanup 2025-05-31 09:47:30 +00:00
20bf6b745b Merge pull request 'some cleanup' (#2) from master into main
Reviewed-on: #2
2025-05-31 11:45:23 +02:00
7fa6bdeb3c Merge pull request 'init' (#1) from master into main
Reviewed-on: #1
2025-05-31 11:32:29 +02:00
127 changed files with 68748 additions and 428 deletions

3
.cargo/config.toml Normal file
View File

@@ -0,0 +1,3 @@
[build]
rustflags = ["-C", "debuginfo=1"]
#rustc-wrapper = "sccache"

1
.env Normal file
View File

@@ -0,0 +1 @@
DATABASE_URL=hottub.db

11
.gitignore vendored
View File

@@ -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,7 @@ 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
.mcp.json
*.mp4*

View File

@@ -1,19 +1,62 @@
[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]
warnings = "warn"
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
View 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

View File

@@ -5,3 +5,48 @@ Rust based hottub server
the following URL:
[hottub.spacemoehre.de](hottub://source?url=hottub.spacemoehre.de)
## build it yourself
Get, Build and Host the docker image:
```
git clone https://gitea.spacemoehre.de/simon/hottub
sudo docker build hottub
cd hottub && cargo build --release
nano docker-compose.yml # adjust compose file
sudo docker compose up -d
```
Verify setup, replace the url with your setup url
```
curl -v http://127.0.0.1
```
->
```
* Trying 127.0.0.1:80...
* Connected to 127.0.0.1 (127.0.0.1) port 80
> GET / HTTP/1.1
> Host: 127.0.0.1:80
> User-Agent: curl/8.5.0
> Accept: */*
>
< HTTP/1.1 302 Found
< content-length: 0
< location: hottub://source?url=127.0.0.1:80
< date: Mon, 29 Sep 2025 14:58:15 GMT
<
* Connection #0 to host 127.0.0.1 left intact
```
make sure that you get a code 302 to a `hottub://` url
## Update via git pull
To Update (i.e. for new supported sites) do
```
cd /path/to/hottub && git pull && cargo build --release && sudo docker container restart hottub
```

323
SKILL.md Normal file
View 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
View 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![]
}
}
}
}

434
archive/tube8.rs Normal file
View 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))
}
}

387
build.rs Normal file
View File

@@ -0,0 +1,387 @@
#![deny(warnings)]
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: "pornxp",
module: "pornxp",
ty: "PornxpProvider",
},
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: "porntrex",
module: "porntrex",
ty: "PorntrexProvider",
},
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",
},
ProviderDef {
id: "archivebate",
module: "archivebate",
ty: "ArchivebateProvider",
},
ProviderDef {
id: "archivebate1",
module: "archivebate1",
ty: "ArchivebateProvider",
},
];
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 698 B

BIN
burp/close.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
burp/http_history.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 780 B

BIN
burp/next_button.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 B

20
burp/project_options.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 780 B

BIN
burp/sort.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 B

BIN
burp/start_burp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 818 B

98
burp/start_burp.py Normal file
View 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
View 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
View 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

40
docs/README.md Normal file
View File

@@ -0,0 +1,40 @@
# Hottub Docs
This folder is the fastest handoff path for anyone adding or repairing a channel.
Start here:
1. Read `architecture.md` for the server flow, request lifecycle, and core types.
2. Read `provider-playbook.md` for the exact process to add a new provider or proxy.
3. Use `provider-catalog.md` to find the closest existing implementation to copy.
4. Use `docs/hottubapp/*.html` when you need the client-facing API contract for status, videos, or uploaders.
5. Only then touch `prompts/new-channel.md`; it assumes the docs above exist.
Recommended local workflow:
```bash
cargo check -q
HOT_TUB_PROVIDER=<channel_id> cargo check -q
HOT_TUB_PROVIDER=<channel_id> cargo run --features debug
```
Useful runtime 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=
```
Key facts:
- Hottub is a Rust `ntex` server with providers under `src/providers/`.
- `build.rs` controls compile-time provider registration.
- `/api/videos` is the main provider execution path.
- `/proxy/...` exists for sites whose direct media or thumbnails need a redirect/proxy layer.
- Only three providers currently implement `/api/uploaders`: `hsex`, `omgxxx`, and `vjav`.
- Uploader IDs should be namespaced like `<channel>:<site-local-id>` so `/api/uploaders` can route directly.

313
docs/architecture.md Normal file
View File

@@ -0,0 +1,313 @@
# Hottub Architecture
## Purpose
Hottub is a Rust server that exposes Hot Tub compatible endpoints for channel discovery, video search, uploader lookups, and site-specific proxying. Most work in this repo is adding or repairing a provider module under `src/providers/`.
## Top-Level Structure
- `src/main.rs`: server bootstrap, env loading, database pool, shared requester/cache, route mounting.
- `src/api.rs`: `/api/status`, `/api/videos`, `/api/uploaders`, `/api/test`, `/api/proxies`.
- `src/providers/mod.rs`: provider trait, provider registry, build-time provider selection, status decoration, runtime validation, panic/error guards.
- `src/providers/*.rs`: one module per channel/provider.
- `src/proxy.rs`: route table for `/proxy/...`.
- `src/proxies/*.rs`: redirect/media/thumb proxy implementations.
- `src/videos.rs`: request/response payloads, `VideoItem`, `VideoFormat`, `ServerOptions`.
- `src/status.rs`: status/channel/group payloads.
- `src/uploaders.rs`: uploader request/profile payloads.
- `src/util/requester.rs`: outbound HTTP with cookies, optional Burp proxying, Jina fallback, and FlareSolverr fallback.
- `build.rs`: compile-time provider registry generation and single-provider build support.
## Startup Flow
1. `main` loads `.env` and ensures `RUST_LOG` is set.
2. It creates the Diesel SQLite pool from `DATABASE_URL`.
3. It creates a shared `Requester`, enables Burp proxying when `PROXY != 0`, and builds the LRU video cache.
4. It configures provider runtime validation in `providers::configure_runtime_validation`.
5. It spawns a background thread that forces provider initialization via `providers::init_providers_now()`.
6. It starts an `ntex` HTTP server on `0.0.0.0:18080`.
## Runtime Environment
Important environment variables:
- `DATABASE_URL`: required SQLite path.
- `RUST_LOG`: defaults to `warn` if unset.
- `PROXY`: enables Burp proxying when not equal to `0`.
- `BURP_URL`: outbound proxy URL used when `PROXY` is enabled.
- `FLARE_URL`: FlareSolverr endpoint used as the last HTML-fetch fallback.
- `DOMAIN`: used by the `/` redirect target.
- `DISCORD_WEBHOOK`: enables `/api/test` and provider error reporting.
Bundled reference material:
- `docs/hottubapp/📡 Status - Hot Tub Docs.html`
- `docs/hottubapp/🎬 Videos - Hot Tub Docs.html`
- `docs/hottubapp/👤 Uploaders - Hot Tub Docs.html`
Those HTML files are useful when a provider author needs to confirm the expected client payload shape without reading Rust structs first.
## Build-Time Provider Selection
`build.rs` reads `HOT_TUB_PROVIDER` or `HOTTUB_PROVIDER`.
- If unset, every provider in `build.rs` is compiled and registered.
- If set, only that provider is compiled into the binary.
- In a single-provider build, `/api/videos` remaps `"channel": "all"` to the compiled provider.
Generated files in `OUT_DIR` are included by `src/providers/mod.rs`:
- `provider_modules.rs`
- `provider_registry.rs`
- `provider_metadata_fn.rs`
- `provider_selection.rs`
This means adding a new provider always requires updating `build.rs`.
## HTTP Surface
### `/`
Returns a `302` redirect to `hottub://source?url=<DOMAIN-or-request-host>`.
### `/api/status`
Builds the channel list by iterating `ALL_PROVIDERS` and calling `Provider::get_channel`.
Important behavior:
- The `User-Agent` is parsed into `ClientVersion`.
- A provider can hide itself by returning `None`.
- `providers::build_status_response` decorates channels with `groupKey`, top tags, runtime status, and sort order.
- Some heavy status filters are intentionally removed from the client-facing response. The server still accepts them in `/api/videos`.
### `/api/videos`
This is the main provider execution path.
Flow:
1. Parse `VideosRequest`.
2. Normalize `channel`, `sort`, `query`, `page`, and `perPage`.
3. Build `ServerOptions`.
4. If `query` is a full `http://` or `https://` URL, try the `yt-dlp -J` fast path first.
5. Otherwise call `provider.get_videos(...)` through `run_provider_guarded`.
6. For quoted queries like `"teacher"`, apply a literal substring filter after provider fetch.
7. Spawn a background prefetch for the next page.
8. For short videos (`duration <= 120`), populate `preview` from the main URL or first format.
Important behavior:
- Leading `#` is stripped from queries before provider dispatch.
- `"all"` uses `AllProvider` in a normal build, but resolves to the single compiled provider in a single-provider build.
- Older `Hot Tub/38` clients are patched by replacing `video.url` with the last format URL when formats exist.
### `/api/uploaders`
Uploader lookup is optional and provider-specific.
Important behavior:
- At least one of `uploaderId` or `uploaderName` is required.
- If `uploaderId` looks like `channel:id`, the server directly targets that provider.
- Otherwise it scans all providers and returns the best exact-name match.
- Only `hsex`, `omgxxx`, and `vjav` currently implement `get_uploader`.
- In practice, provider-owned uploader IDs should be namespaced, for example `vjav:12345` or `hsex:author_slug`.
### `/api/test`
Sends a Discord error test if `DISCORD_WEBHOOK` is configured.
### `/api/proxies`
Returns the background-fetched outbound proxy snapshot from `src/util/proxy.rs`.
## Core Data Structures
### `VideosRequest`
Defined in `src/videos.rs`. Common fields used by providers:
- `channel`
- `sort`
- `query`
- `page`
- `perPage`
- `featured`
- `category`
- `sites`
- `all_provider_sites`
- `filter`
- `language`
- `networks`
- `stars`
- `categories`
- `duration`
- `sexuality`
### `ServerOptions`
The servers normalized option bag. Providers should read from this instead of reparsing the raw API request.
Important fields:
- `public_url_base`: needed when generating `/proxy/...` URLs.
- `requester`: the shared request client with cookies/debug trace/proxy state.
- `sort`, `sites`, `filter`, `category`, `language`, `network`, `stars`, `categories`, `duration`, `sexuality`.
### `VideoItem`
Minimum useful fields for a provider:
- `id`
- `title`
- `url`
- `channel`
- `thumb`
- `duration`
High-value optional fields:
- `views`
- `rating`
- `uploader`
- `uploaderUrl`
- `uploaderId`
- `tags`
- `uploadedAt`
- `formats`
- `preview`
- `aspectRatio`
Avoid setting `embed` for new providers unless the site truly needs it.
### `VideoFormat`
Use `formats` when:
- the site returns a better direct media URL than the page URL
- HLS or multiple qualities exist
- extra HTTP headers such as `Referer` are required
Use `http_header` or `add_http_header` when the player endpoint needs request headers.
### `Channel` and `ChannelOption`
Each providers `get_channel` returns the status metadata exposed by `/api/status`.
Typical option IDs used across the repo:
- `sort`
- `filter`
- `sites`
- `category`
- `language`
- `networks`
- `stars`
- `categories`
Use the same IDs when possible so the server and client behavior stay consistent.
### `UploaderProfile`
If a provider supports `/api/uploaders`, keep the ID routable:
- preferred format: `<channel>:<site-local-id>`
- examples in the repo: `vjav:<user_id>`, `hsex:<author>`, `omgxxx:<kind>:<id>`
This lets `src/api.rs` derive the owning provider immediately.
## Provider Contract
Defined in `src/providers/mod.rs`:
- `async fn get_videos(...) -> Vec<VideoItem>`
- `fn get_channel(clientversion: ClientVersion) -> Option<Channel>`
- `async fn get_uploader(...) -> Result<Option<UploaderProfile>, String>` optional
The server wraps provider execution in:
- `run_provider_guarded` for video paths
- `run_uploader_provider_guarded` for uploader paths
Panics and reported errors trigger runtime validation and optional Discord reporting.
## Runtime Validation and Error Handling
`src/providers/mod.rs` includes a validation subsystem that:
- runs a small sample request against a provider after failures
- checks that enough video items exist
- tries media URLs or format URLs with a `Range` header
- marks repeated failures over time
This means a provider that returns page URLs but no real media/formats may pass visually but still fail operationally.
## Requester Behavior
`src/util/requester.rs` is the standard outbound HTTP layer.
Capabilities:
- shared cookie jar across clones
- optional Burp proxying via `PROXY` and `BURP_URL`
- direct request retries for `429`
- Jina mirror fallback for blocked HTML fetches
- FlareSolverr fallback via `FLARE_URL`
- raw response helpers for media validation and custom headers
Use the shared requester from `ServerOptions` through `requester_or_default`. Do not instantiate a brand-new requester in normal provider fetch paths unless you have a very specific reason.
FlareSolverr note:
- `src/util/flaresolverr.rs` keeps a reusable session pool pattern by rotating a ready session per solve.
- If a provider only works after anti-bot negotiation, the shared requester is the path that benefits from that solved session and cookie state.
## Proxy Subsystem
There are two proxy styles.
### Redirect proxies
These take a provider-specific endpoint and return `302 Location: <resolved-media-url>`.
Examples:
- `/proxy/spankbang/...`
- `/proxy/sxyprn/...`
- `/proxy/pornhd3x/...`
- `/proxy/vjav/...`
### Media or image proxies
These actively fetch media or thumbnails and stream or rewrite the response.
Examples:
- `/proxy/noodlemagazine/...`
- `/proxy/noodlemagazine-thumb/...`
- `/proxy/shooshtime-media/...`
- `/proxy/hanime-cdn/...`
If a site only needs a referer-preserving redirect, use a redirect proxy. If manifests, relative playlist entries, cookies, or binary thumbs need rewriting, use a media/image proxy.
## Best Existing Templates
Use the closest existing provider instead of inventing a new style.
- `src/providers/vjav.rs`: rich API-backed provider with tags, uploader support, and detail enrichment.
- `src/providers/hsex.rs`: HTML scraping with background-loaded filters, uploader support, and direct HLS formats.
- `src/providers/omgxxx.rs`: large filter catalogs and uploader lookup by site/network identity.
- `src/providers/noodlemagazine.rs`: proxied media/thumbs, Jina fallback, and mirrored listing parsing.
- `src/providers/pornhd3x.rs`: complex filter catalogs, detail enrichment, and proxy-generated playback URLs.
- `src/providers/spankbang.rs`: anti-bot handling and a redirect-proxy-based media strategy.
## Important Gotchas
- New providers must export `CHANNEL_METADATA`.
- New providers must be listed in `build.rs` or they will never compile into the registry.
- If a provider returns proxied URLs, it usually also needs `options.public_url_base`.
- Keep filter IDs stable. The `title` is for display; the `id` is what the provider matches on.
- `categories` in `Channel` are not the same as `ChannelOption { id: "categories" }`.
- `/api/status` sanitizes some options away from the client-facing payload. That does not mean the provider option is useless in `/api/videos`.
- If a site needs per-request cookies or a solved user agent, rely on the shared requester.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

97
docs/provider-catalog.md Normal file
View File

@@ -0,0 +1,97 @@
# Provider And Proxy Catalog
This is the current implementation inventory as of this snapshot of the repo. Use it to find the nearest existing pattern before adding a new channel.
## Providers
| Provider | Group | `/api/uploaders` | Uses local `/proxy` | Notes |
| --- | --- | --- | --- | --- |
| `all` | `meta-search` | no | no | Aggregates all compiled providers. |
| `archivebate` | `live-cams` | no | no | Livewire-backed cam archive listings with platform/gender/profile shortcuts. |
| `beeg` | `mainstream-tube` | no | no | Basic mainstream tube pattern. |
| `chaturbate` | `live-cams` | no | no | Live cam channel. |
| `freepornvideosxxx` | `studio-network` | no | no | Studio-style scraper. |
| `freeuseporn` | `fetish-kink` | no | no | Fetish archive pattern. |
| `hanime` | `hentai-animation` | no | yes | Uses proxied CDN/thumb handling. |
| `heavyfetish` | `fetish-kink` | no | no | Direct media handling. |
| `hentaihaven` | `hentai-animation` | no | no | HLS format builder pattern. |
| `homoxxx` | `gay-male` | no | no | Gay category grouping example. |
| `hqporner` | `studio-network` | no | yes | Uses thumb and redirect proxy helpers. |
| `hsex` | `chinese` | yes | no | Strong template for tags, uploaders, and direct HLS formats. |
| `hypnotube` | `fetish-kink` | no | no | Fetish/tube hybrid. |
| `javtiful` | `jav` | no | no | JAV channel family. |
| `missav` | `jav` | no | no | HLS format pattern. |
| `noodlemagazine` | `mainstream-tube` | no | yes | Best template for media and thumbnail proxying. |
| `okporn` | `mainstream-tube` | no | no | Simple mainstream archive. |
| `okxxx` | `mainstream-tube` | no | no | Mainstream search/archive pattern. |
| `omgxxx` | `studio-network` | yes | no | Best template for sites/networks/stars filter catalogs. |
| `paradisehill` | `mainstream-tube` | no | no | Simple page scraper. |
| `perfectgirls` | `studio-network` | no | no | Studio archive. |
| `perverzija` | `studio-network` | no | no | Multi-format/HLS examples. |
| `pimpbunny` | `onlyfans` | no | yes | Proxy-backed playback and thumbnail handling. |
| `pmvhaven` | `pmv-compilation` | no | no | PMV grouping example. |
| `porn00` | `mainstream-tube` | no | no | Lightweight scraper. |
| `porn4fans` | `onlyfans` | no | no | OnlyFans-like grouping example. |
| `porndish` | `studio-network` | no | yes | Redirect proxy plus thumb proxy usage. |
| `pornhat` | `mainstream-tube` | no | no | Basic tube provider. |
| `pornhd3x` | `studio-network` | no | yes | Best template for complex catalogs and redirect proxy generation. |
| `pornhub` | `mainstream-tube` | no | no | Rich metadata and format examples. |
| `pornmz` | `mainstream-tube` | no | no | Mainstream archive. |
| `pornzog` | `mainstream-tube` | no | no | Basic list/detail scraper. |
| `porntrex` | `mainstream-tube` | no | no | KVS-style HTML archive with direct MP4 formats and tag-aware search shortcuts. |
| `redtube` | `mainstream-tube` | no | no | Mainstream archive. |
| `rule34gen` | `ai` | no | no | AI group example. |
| `rule34video` | `hentai-animation` | no | no | Hentai group example. |
| `sextb` | `jav` | no | no | JAV family provider. |
| `shooshtime` | `onlyfans` | no | yes | Redirect proxy plus dedicated media route. |
| `spankbang` | `mainstream-tube` | no | yes | Best template for redirect proxy plus anti-bot fetches. |
| `supjav` | `jav` | no | no | JAV/HLS and uploader-id examples. |
| `sxyprn` | `mainstream-tube` | no | yes | Redirect proxy helper usage. |
| `tnaflix` | `mainstream-tube` | no | no | Mainstream tube provider. |
| `tokyomotion` | `jav` | no | no | JAV/tube hybrid. |
| `viralxxxporn` | `mainstream-tube` | no | no | Basic parser with format extraction. |
| `vjav` | `jav` | yes | no | Best API-style template with uploaders and tag-id lookup maps. |
| `vrporn` | `studio-network` | no | no | Multi-format direct playback. |
| `xfree` | `tiktok` | no | no | Short-form grouping example. |
| `xxdbx` | `onlyfans` | no | no | OnlyFans-like grouping example. |
| `xxthots` | `onlyfans` | no | no | OnlyFans-like metadata example. |
| `yesporn` | `mainstream-tube` | no | no | Preview format examples. |
| `youjizz` | `mainstream-tube` | no | no | Mainstream tube provider. |
## Proxy Routes
### Redirect proxies
These resolve a provider-specific input into a `302 Location`.
- `/proxy/doodstream/{endpoint}*`
- `/proxy/sxyprn/{endpoint}*`
- `/proxy/javtiful/{endpoint}*`
- `/proxy/spankbang/{endpoint}*`
- `/proxy/porndish/{endpoint}*`
- `/proxy/hqporner/{endpoint}*`
- `/proxy/heavyfetish/{endpoint}*`
- `/proxy/vjav/{endpoint}*`
- `/proxy/pornhd3x/{endpoint}*`
- `/proxy/shooshtime/{endpoint}*`
- `/proxy/pimpbunny/{endpoint}*`
### Media/image proxies
These return binary media or images, sometimes rewriting manifests or forwarding cookies/referers.
- `/proxy/shooshtime-media/{endpoint}*`
- `/proxy/noodlemagazine/{endpoint}*`
- `/proxy/noodlemagazine-thumb/{endpoint}*`
- `/proxy/hanime-cdn/{endpoint}*`
- `/proxy/hqporner-thumb/{endpoint}*`
- `/proxy/porndish-thumb/{endpoint}*`
- `/proxy/pornhub-thumb/{endpoint}*`
## Best Copy Sources By Problem
- Need uploader support: copy `hsex`, `omgxxx`, or `vjav`.
- Need proxied media: copy `noodlemagazine`.
- Need proxied redirect-only playback: copy `spankbang` or `pornhd3x`.
- Need big background-loaded filter catalogs: copy `pornhd3x` or `omgxxx`.
- Need tag title to site-ID lookup maps: copy `vjav` or `hsex`.

349
docs/provider-playbook.md Normal file
View File

@@ -0,0 +1,349 @@
# New Provider Playbook
This is the implementation checklist for adding a working channel with the least guessing.
## Definition Of Done
A provider is not done when it compiles. It is done when:
1. `/api/status` shows the channel with sensible options and grouping.
2. `/api/videos` returns real items for the default feed.
3. Search works.
4. Pagination works.
5. Thumbnails load.
6. `video.url` or at least one `formats[*].url` resolves to playable media.
7. If the site needs proxying, the `/proxy/...` route works.
8. `HOT_TUB_PROVIDER=<id> cargo check -q` passes.
## Files To Touch
Always:
- `build.rs`
- `src/providers/<channel_id>.rs`
Sometimes:
- `src/proxy.rs`
- `src/proxies/<channel_id>.rs`
- `src/proxies/<channel_id>thumb.rs`
- `prompts/new-channel.md` if you are improving the handoff prompt
- `docs/provider-catalog.md` if you add a new provider or proxy
## Step 1: Pick The Closest Template
Do not start from an empty file.
Choose the nearest match:
- API-first site with tags/uploader metadata: copy `vjav.rs`
- HTML site with background-loaded tags/uploaders: copy `hsex.rs`
- Site with multiple large catalogs like sites/networks/stars: copy `omgxxx.rs`
- Site whose media or thumbs need local proxying: copy `noodlemagazine.rs`, `pornhd3x.rs`, `spankbang.rs`, or `porndish.rs`
- Very simple archive/search site: copy a small provider from `mainstream-tube`
Before writing code, confirm the site shape:
1. home or latest feed URL
2. search URL and page 2 URL
3. detail page URL shape
4. player request or manifest request
5. thumbnail host and whether it needs referer/cookies
6. tag/category/uploader/studio routes if they exist
7. whether the site exposes JSON endpoints that are easier than HTML scraping
Use browser/network tooling for this if needed. Do not guess URL patterns from one page.
## Step 2: Register The Provider
Add the provider to `build.rs`:
- `id`: channel id used by `/api/videos`
- `module`: Rust file name
- `ty`: provider struct name
If this is missing, the server will not discover the provider.
## Step 3: Define Channel Metadata
Every provider should export:
```rust
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
crate::providers::ProviderChannelMetadata {
group_id: "...",
tags: &["...", "...", "..."],
};
```
Pick `group_id` from the existing set in `src/providers/mod.rs`:
- `meta-search`
- `mainstream-tube`
- `tiktok`
- `studio-network`
- `amateur-homemade`
- `onlyfans`
- `chinese`
- `jav`
- `fetish-kink`
- `hentai-animation`
- `ai`
- `gay-male`
- `live-cams`
- `pmv-compilation`
## Step 4: Build The Channel Surface
Implement `build_channel` or equivalent and return it from `get_channel`.
Required:
- `id`
- `name`
- `description`
- `favicon`
- `status`
- `nsfw`
Recommended:
- `cacheDuration: Some(1800)` unless the site is unusually stable
- use standard option IDs like `sort`, `filter`, `sites`, `category`, `stars`, `categories`
- keep options minimal at first; only expose filters that actually work in `get_videos`
The option `id` values matter more than the display `title`.
## Step 5: Model Provider Routing Explicitly
Create a local enum like:
```rust
enum Target {
Latest,
Search { query: String },
Tag { slug: String },
Uploader { id: String },
}
```
Then write one function that resolves `sort`, `query`, `filter`, `sites`, and related options into a `Target`.
This is easier to debug than scattering URL decisions across the provider.
## Step 6: Load Filter Catalogs In The Background If Needed
If the site exposes tags, uploaders, studios, networks, or stars:
- store them in `Arc<RwLock<Vec<FilterOption>>>`
- initialize them with an `All` option
- spawn a background thread in `new()`
- create a tiny Tokio runtime inside that thread
- fill the lists without blocking server startup
Patterns:
- `hsex.rs`
- `omgxxx.rs`
- `pornhd3x.rs`
- `vjav.rs`
If tags or uploaders need stable IDs, keep a lookup map such as:
- `HashMap<String, String>` from title to site ID
- `HashMap<String, String>` from site ID to URL target
Normalize lookup keys to lowercase trimmed strings.
## Step 7: Fetch Pages Through The Shared Requester
In `get_videos`, start with:
```rust
let mut requester = requester_or_default(&options, CHANNEL_ID, "get_videos");
```
Use it for HTML, JSON, and raw media requests.
Why:
- it preserves cookies
- it carries debug trace IDs
- it respects Burp proxying
- it can fall back to Jina or FlareSolverr
## Step 8: Parse Listing Cards First, Then Enrich Only If Needed
Preferred flow:
1. Fetch the archive or search page.
2. Parse a lightweight list of stubs.
3. Return list data directly if enough metadata is already present.
4. Fetch detail pages or JSON endpoints only for fields the card does not expose.
Use bounded concurrency for detail enrichment. Existing providers usually use `futures::stream` with `buffer_unordered`.
## Step 9: Build High-Quality `VideoItem`s
Always fill:
- `id`
- `title`
- `url`
- `channel`
- `thumb`
- `duration`
Fill when available:
- `views`
- `rating`
- `uploader`
- `uploaderUrl`
- `uploaderId`
- `tags`
- `uploadedAt`
- `preview`
- `aspectRatio`
- `formats`
Rules:
- Keep `tags` as a list of displayable titles.
- Keep uploader data as structured fields, not mashed into the title.
- If you support uploader profiles, set `uploaderId` to a namespaced value like `<channel>:<site-local-id>`.
- Do not include `embed` unless the provider truly needs it.
- If direct media exists, prefer `formats` and keep `url` stable.
## Step 10: Decide Whether A Proxy Is Required
Use no proxy when:
- page URLs are enough and the client can resolve media itself
- or direct media URLs already work cleanly
Use a redirect proxy when:
- the provider must turn a detail URL into a resolved media URL
- headers/cookies do not need full response rewriting
Use a media/image proxy when:
- the site requires a referer for every fetch
- thumbnails need cookie-backed access
- manifests contain relative URIs that must be rewritten
- the server must stream binary content itself
If a proxy is needed:
1. add `src/proxies/<id>.rs`
2. wire the route in `src/proxy.rs`
3. generate provider URLs with `build_proxy_url(&options, "<id>", target)`
## Step 11: Implement Search Correctly
Check for three search modes:
1. native site search endpoint
2. tag/uploader shortcut search from preloaded filter catalogs
3. literal client-side substring search after fetch, triggered by quoted queries
Important server behavior:
- `#tag` becomes `tag`
- `"teacher"` becomes a literal post-fetch filter
- raw URL queries may bypass the provider through the `yt-dlp` fast path
Provider guidance:
- if the query matches a known tag/uploader shortcut, prefer the sites direct archive URL instead of generic search
- otherwise fall back to the sites keyword search
## Step 12: Support Pagination Explicitly
Do not assume pagination is `?page=N`.
Confirm:
- archive page 2 URL shape
- search page 2 URL shape
- tag page 2 URL shape
- uploader page 2 URL shape
If the site uses infinite scroll or an XHR endpoint, document that in code comments and hit the underlying endpoint directly.
## Step 13: Only Add `/api/uploaders` When The Site Has Real Uploader Identity
Uploader support is optional. Only implement it when the site exposes stable uploader pages or IDs.
Use `hsex.rs`, `omgxxx.rs`, or `vjav.rs` as the template.
Minimum expectations for `UploaderProfile`:
- stable `id`
- `name`
- `channel`
- `videoCount`
- `totalViews`
Nice to have:
- `avatar`
- `description`
- `videos`
- `layout`
- per-channel stats
## Validation Checklist
Run all of these:
```bash
cargo check -q
HOT_TUB_PROVIDER=<channel_id> cargo check -q
HOT_TUB_PROVIDER=<channel_id> cargo run --features debug
```
Then hit:
```bash
curl -s http://127.0.0.1:18080/api/status \
-H 'User-Agent: Hot%20Tub/22c CFNetwork/1494.0.7 Darwin/23.4.0' | jq
```
```bash
curl -s http://127.0.0.1:18080/api/videos \
-H 'Content-Type: application/json' \
-d '{"channel":"<channel_id>","sort":"new","page":1,"perPage":10}' | jq
```
Also verify:
- search query works
- page 2 works
- tag shortcut works if implemented
- uploader shortcut works if implemented
- `yt-dlp '<video.url or first format url>'` resolves media
- thumbnail URL returns an image
- proxy route returns a `302` or working media body, whichever is expected
- if uploaders are implemented, `/api/uploaders` works with both `uploaderId` and `uploaderName`
## Common Failure Modes
- Forgot `build.rs` entry.
- Returned page URLs but no playable media/formats.
- Used a local requester instead of the shared one and lost cookies.
- Built `/proxy/...` URLs without `public_url_base`.
- Put human-readable titles into filter IDs, making routing brittle.
- Added huge option lists to the status response without background loading.
- Implemented search but not search pagination.
- Implemented proxies but forgot to test them independently with `curl -I`.
## Best Reference Matrix
- Rich uploader support: `vjav.rs`, `hsex.rs`, `omgxxx.rs`
- Tag and uploader lookup maps: `vjav.rs`, `hsex.rs`
- Background catalog loading: `hsex.rs`, `omgxxx.rs`, `pornhd3x.rs`
- Redirect proxy: `spankbang.rs` plus `src/proxies/spankbang.rs`
- Manifest or image proxy: `noodlemagazine.rs` plus `src/proxies/noodlemagazine.rs`
- Complex detail enrichment: `pornhd3x.rs`

View File

@@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
DROP TABLE videos

View 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
)

128
prompts/new-channel.md Normal file
View File

@@ -0,0 +1,128 @@
Implement a new Hottub provider for `archivebate1` at `https://archivebate1.com`.
You are working inside the Hottub Rust server. Your job is to add a functioning provider module that can survive handoff to another model with minimal guesswork. Do not stop at code generation. Carry the work through code, validation, and documentation updates.
Execution order is mandatory:
1. Read the repo docs.
2. Inspect the target site and collect evidence about routes, player/media requests, and pagination.
3. Choose the closest existing provider/proxy as the template.
4. Implement the provider.
5. Validate it end to end.
6. Update docs if the new provider adds a new pattern.
Do not start coding until you know:
- latest/default feed URL
- search URL
- page 2 URL
- detail page URL
- actual media request or manifest URL
- thumbnail behavior
- whether tag/uploader/studio pages exist
- whether the site has a JSON API that is easier than HTML scraping
Read these files first:
1. `docs/README.md`
2. `docs/architecture.md`
3. `docs/provider-playbook.md`
4. `docs/provider-catalog.md`
5. `docs/hottubapp/🎬 Videos - Hot Tub Docs.html`
6. `docs/hottubapp/📡 Status - Hot Tub Docs.html`
7. `docs/hottubapp/👤 Uploaders - Hot Tub Docs.html`
Then inspect the closest existing providers and proxies before coding. Pick the nearest template instead of starting from scratch.
Template selection rules:
- Use `src/providers/vjav.rs` if the target site has JSON APIs, rich tag metadata, or stable uploader identities.
- Use `src/providers/hsex.rs` if the target site is mostly HTML and needs background-loaded tags/uploaders.
- Use `src/providers/omgxxx.rs` if the site exposes multiple large filter catalogs like sites, networks, models, or studios.
- Use `src/providers/noodlemagazine.rs`, `src/providers/pornhd3x.rs`, or `src/providers/spankbang.rs` if media or thumbnails require local `/proxy/...` routes.
Required deliverables:
1. Add a new provider file at `src/providers/<channel_id>.rs`.
2. Register it in `build.rs`.
3. Export `CHANNEL_METADATA` with the correct group.
4. Implement `get_channel` with sane options and descriptions.
5. Implement `get_videos` so the default feed works, search works, and page 2 works.
6. If the site needs proxying, add `src/proxies/<channel_id>.rs` and wire `src/proxy.rs`.
7. Reuse `requester_or_default(&options, CHANNEL_ID, "...")` for outbound requests.
8. Return high-quality `VideoItem`s with the best metadata the site exposes.
9. Do not use `embed` unless the site truly requires it.
10. Update `docs/provider-catalog.md` if you add a new provider or proxy.
Implementation requirements:
- Determine the real site routing for:
- default/latest listing
- search
- page 2 and later
- tag/category shortcuts
- uploader/studio/model shortcuts if the site exposes them
- featured/trending/most-viewed or similar alternate feeds
- Model routing explicitly with a local enum like `Target`.
- If the site exposes tag or uploader IDs, keep a lookup map from normalized display title to site ID/URL target.
- Put tags into `VideoItem.tags`.
- Put uploader name/url/id into `uploader`, `uploaderUrl`, and `uploaderId` when available.
- If uploader support is implemented, use a namespaced `uploaderId` such as `<channel>:<site-local-id>` so `/api/uploaders` can route directly.
- If the query matches a known tag/uploader shortcut, use the direct archive URL instead of generic search.
- If the site exposes real media URLs or HLS manifests, populate `formats`.
- If the video page URL can be directly downloaded by yt-dlp, set `video.url` to the page URL and do not populate `formats`, as yt-dlp will extract formats dynamically.
- If direct playback needs a referer/cookie transform, use a local `/proxy/...` route built with `build_proxy_url(&options, "...", target)`.
- Keep the first version small and reliable. Add extra filters only after the default feed, search, and pagination are working.
Validation requirements:
1. `cargo check -q`
2. `HOT_TUB_PROVIDER=<channel_id> cargo check -q`
3. `HOT_TUB_PROVIDER=<channel_id> cargo run --features debug`
4. Verify `/api/status` exposes the new channel.
5. Verify `/api/videos` returns results for:
- default feed
- search query
- page 2
- at least one tag/uploader shortcut if implemented
6. Verify thumbnails load.
7. Verify `yt-dlp` can resolve `video.url` (if formats are not populated) or one of `formats[*].url` (if formats are populated).
8. If a proxy route exists, verify it directly with `curl -I` or equivalent.
Testing commands to run:
```bash
curl -s http://127.0.0.1:18080/api/status \
-H 'User-Agent: Hot%20Tub/22c CFNetwork/1494.0.7 Darwin/23.4.0' | jq
```
```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":"<channel_id>","sort":"new","page":1,"perPage":10}' | jq
```
```bash
curl -s http://127.0.0.1:18080/api/videos \
-H 'Content-Type: application/json' \
-d '{"channel":"<channel_id>","query":"test","page":1,"perPage":10}' | jq
```
Important Hottub-specific rules:
- Do not invent a new provider style if an existing provider already matches the site shape.
- Do not forget `build.rs`; missing registration means the provider does not exist at runtime.
- Do not create a brand-new requester in normal provider fetches unless you have a strong reason.
- Do not assume page URLs are playable media URLs.
- Do not expose status filters that you did not implement in `get_videos`.
- Do not populate `formats` if the page URL is yt-dlp compatible; instead, set `video.url` to the page URL.
- Do not finish without checking at least one returned media URL with `yt-dlp`.
- Do not claim pagination works unless page 2 was verified.
Completion format:
1. Briefly state which existing provider/proxy you used as the template and why.
2. List the files changed.
3. Report the exact validation commands you ran and whether they passed.
4. Report any residual limitations or site behaviors that still need follow-up.

426
sf-symbols.md Normal file
View 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-->

1206
src/api.rs

File diff suppressed because it is too large Load Diff

91
src/db.rs Normal file
View 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(())
}

View File

@@ -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
View 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
View 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),
})
}
}

1427
src/providers/archivebate.rs Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

466
src/providers/beeg.rs Normal file
View 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
View 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: "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))
}
}

View 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))
}
}

View 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 &amp; The Truth"/>
</div>
<div class="info">
<span class="v-name">Nicole Kitt &amp; 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
View 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))
}
}

1279
src/providers/heavyfetish.rs Normal file

File diff suppressed because it is too large Load Diff

View 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))
}
}

432
src/providers/homoxxx.rs Normal file
View File

@@ -0,0 +1,432 @@
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);
let trimmed = query.trim().trim_start_matches('@');
if let Some((kind, value)) = trimmed.split_once(':') {
let kind = kind.trim().to_ascii_lowercase();
let value = value.trim().replace(' ', "-");
if !value.is_empty()
&& matches!(
kind.as_str(),
"models" | "pornstars" | "stars" | "channels" | "categories" | "tags"
)
{
video_url = format!("{}/{}/{}/", self.url, kind, value);
}
}
// 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))
}
}

407
src/providers/hqporner.rs Normal file
View File

@@ -0,0 +1,407 @@
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 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, &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, &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,
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();
raw_videos
.into_iter()
.filter_map(|seg| self.get_video_item(seg, options).ok())
.collect()
}
fn get_video_item(&self, seg: String, options: &ServerOptions) -> Result<VideoItem> {
let detail_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 = detail_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 stripped_detail_url = crate::providers::strip_url_scheme(&detail_url);
let proxied_url = crate::providers::build_proxy_url(
options,
"hqporner",
&stripped_detail_url,
);
let quality_target = |quality: &str| -> String {
format!("{stripped_detail_url}/__quality__/{quality}")
};
let formats = vec![
VideoFormat::new(
crate::providers::build_proxy_url(options, "hqporner", &quality_target("1080")),
"1080p".to_string(),
"mp4".to_string(),
)
.format_id("1080p".to_string())
.format_note("1080p Full HD".to_string()),
VideoFormat::new(
crate::providers::build_proxy_url(options, "hqporner", &quality_target("720")),
"720p".to_string(),
"mp4".to_string(),
)
.format_id("720p".to_string())
.format_note("720p HD".to_string()),
VideoFormat::new(
crate::providers::build_proxy_url(options, "hqporner", &quality_target("360")),
"360p".to_string(),
"mp4".to_string(),
)
.format_id("360p".to_string())
.format_note("360p".to_string()),
];
Ok(VideoItem::new(
id,
title,
proxied_url,
"hqporner".into(),
thumb,
duration,
)
.formats(formats))
}
}
#[async_trait]
impl Provider for HqpornerProvider {
async fn get_videos(
&self,
cache: VideoCache,
_pool: DbPool,
sort: String,
query: Option<String>,
page: String,
_per_page: String,
options: ServerOptions,
) -> Vec<VideoItem> {
let page_num = page.parse::<u8>().unwrap_or(1);
let res = match query {
Some(q) => self.query(cache, page_num, &q, options).await,
None => self.get(cache, page_num, &sort, options).await,
};
res.unwrap_or_else(|e| {
eprintln!("Hqporner error: {e}");
let _ = send_discord_error_report(
e.to_string(),
Some(format_error_chain(&e)),
None,
None,
file!(),
line!(),
module_path!(),
);
vec![]
})
}
fn get_channel(&self, v: ClientVersion) -> Option<Channel> {
Some(self.build_channel(v))
}
}

1368
src/providers/hsex.rs Normal file

File diff suppressed because it is too large Load Diff

469
src/providers/hypnotube.rs Normal file
View 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))
}
}

474
src/providers/javtiful.rs Normal file
View File

@@ -0,0 +1,474 @@
use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::{Provider, build_proxy_url, strip_url_scheme};
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 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: "relevance".into(),
title: "Relevance".into(),
},
FilterOption {
id: "latest".into(),
title: "Latest".into(),
},
FilterOption {
id: "popular".into(),
title: "Popular".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 {
"latest" => "sort=latest&",
"popular" => "sort=popular&",
_ => "",
};
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!(
"<a class=\"front-pagination-link is-active\" href=\"/videos\" aria-current=\"page\">{}</a>",
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("") {
"latest" => "sort=latest&",
"popular" => "sort=popular&",
_ => "",
};
let video_url = format!(
"{}/search?{}q={}&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!(
"<a class=\"front-pagination-link is-active\" href=\"/videos\" aria-current=\"page\">{}</a>",
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("front-pagination")
.next()
.and_then(|s| s.split("front-video-grid").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("\"front-video-card\"")
.skip(1)
.filter(|seg| !seg.contains("front-ad-card"))
.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 = format!(
"{}{}",
self.url,
seg.split(" href=\"")
.nth(1)
.and_then(|s| s.split('"').next())
.ok_or_else(|| ErrorKind::Parse(format!("video url\n\n{seg}")))?
.to_string()
);
let mut title = match seg.contains("front-video-title") {
true => seg
.split("front-video-title")
.nth(1)
.and_then(|s| s.split('>').nth(1))
.and_then(|s| s.split('<').next())
.ok_or_else(|| ErrorKind::Parse(format!("video title\n\n{seg}")))?
.trim()
.to_string(),
false => seg
.split("alt=\"")
.nth(1)
.and_then(|s| s.split('\"').next())
.ok_or_else(|| ErrorKind::Parse(format!("video title\n\n{seg}")))?
.trim()
.to_string(),
};
title = decode(title.as_bytes())
.to_string()
.unwrap_or(title)
.titlecase();
let id = video_url
.split('/')
.filter(|s| !s.is_empty())
.nth(2)
.and_then(|s| s.split('.').next())
.ok_or_else(|| ErrorKind::Parse(format!("video id\n\n{seg}")))?
.to_string();
let thumb_block = seg.split("<img ").nth(1);
let thumb = match thumb_block {
Some(block) => format!("{}{}", self.url,block
.split("src=\"")
.nth(1)
.and_then(|s| s.split('"').next())
.unwrap_or("")
.to_string()),
None => "".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("class=\"front-duration-tag\">")
.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, views) = self.extract_media(&video_url, &mut requester).await?;
if preview.len() == 0 {
preview = format!("https://trailers.jav.si/preview/{id}.mp4");
}
let proxy_url = build_proxy_url(
options,
"javtiful",
&strip_url_scheme(video_url.clone().as_str()),
);
let video_item = VideoItem::new(id, title, proxy_url, "javtiful".into(), thumb, duration)
.tags(tags)
.preview(preview)
.views(views);
Ok(video_item)
}
async fn extract_media(
&self,
url: &str,
requester: &mut Requester,
) -> Result<(Vec<String>, 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);
Ok((tags, 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))
}
}

568
src/providers/missav.rs Normal file
View File

@@ -0,0 +1,568 @@
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::collections::HashMap;
use std::sync::{Arc, RwLock};
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,
tag_map: Arc<RwLock<HashMap<String, String>>>,
}
impl MissavProvider {
pub fn new() -> Self {
MissavProvider {
url: "https://missav.ws".to_string(),
tag_map: Arc::new(RwLock::new(HashMap::new())),
}
}
fn normalize_key(value: &str) -> String {
value
.trim()
.to_ascii_lowercase()
.replace(['_', '-'], " ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn humanize_slug(value: &str) -> String {
value
.trim_matches('/')
.replace('-', " ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn insert_tag_mapping(&self, key: &str, path_or_url: &str) {
let normalized = Self::normalize_key(key);
if normalized.is_empty() || path_or_url.trim().is_empty() {
return;
}
if let Ok(mut map) = self.tag_map.write() {
map.insert(normalized, path_or_url.trim().to_string());
}
}
fn resolve_query_url(&self, query: &str, page: u8, sort: &str) -> Option<String> {
let normalized = Self::normalize_key(query);
let mapped = self.tag_map.read().ok()?.get(&normalized)?.clone();
let separator = if mapped.contains('?') { "&" } else { "?" };
let mut url = format!("{mapped}{separator}page={page}");
if !sort.is_empty() {
url.push_str("&sort=");
url.push_str(sort);
}
Some(url)
}
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 mut url_str = format!(
"{}/{}/search/{}?page={}{}",
self.url, language, search_string, page, sort
);
if let Some(mapped_url) = self.resolve_query_url(query, page, &sort.replace("&sort=", "")) {
url_str = mapped_url;
}
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, route_kind) in [
("Actress:", "actress"),
("Actor:", "actor"),
("Maker:", "maker"),
("Genre:", "genre"),
] {
let marker = format!("<span>{}</span>", label);
if let Some(section) = extract(&vid, &marker, "</div>") {
for anchor in section.split("<a ").skip(1) {
let href = anchor
.split("href=\"")
.nth(1)
.and_then(|value| value.split('"').next())
.unwrap_or_default()
.to_string();
let title = anchor
.split("class=\"text-nord13 font-medium\">")
.nth(1)
.and_then(|value| value.split('<').next())
.map(str::trim)
.unwrap_or_default()
.to_string();
if !title.is_empty() {
tags.push(title.clone());
if !href.is_empty() {
let full_url = if href.starts_with("http://") || href.starts_with("https://") {
href.clone()
} else {
format!("{}{}", self.url, href)
};
self.insert_tag_mapping(&title, &full_url);
let slug = href
.trim_matches('/')
.rsplit('/')
.next()
.unwrap_or_default()
.to_string();
if !slug.is_empty() {
self.insert_tag_mapping(&slug, &full_url);
self.insert_tag_mapping(
&format!("{route_kind}:{}", slug),
&full_url,
);
self.insert_tag_mapping(
&format!("{route_kind}:{}", Self::humanize_slug(&slug)),
&full_url,
);
}
}
}
}
}
}
// 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))
}
}

File diff suppressed because it is too large Load Diff

View 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 &amp; 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
View 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))
}
}

551
src/providers/okxxx.rs Normal file
View File

@@ -0,0 +1,551 @@
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::collections::HashMap;
use std::env;
use std::sync::{Arc, RwLock};
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,
tag_map: Arc<RwLock<HashMap<String, String>>>,
}
impl OkxxxProvider {
pub fn new() -> Self {
OkxxxProvider {
url: "https://ok.xxx".to_string(),
tag_map: Arc::new(RwLock::new(HashMap::new())),
}
}
fn normalize_key(value: &str) -> String {
value
.trim()
.to_ascii_lowercase()
.replace(['_', '-'], " ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn humanize_slug(value: &str) -> String {
value
.trim_matches('/')
.replace('-', " ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn insert_tag_mapping(&self, kind: &str, slug: &str, title: Option<&str>) {
let slug = slug.trim().trim_matches('/');
if slug.is_empty() {
return;
}
let path = format!("{kind}/{slug}");
if let Ok(mut map) = self.tag_map.write() {
map.insert(Self::normalize_key(slug), path.clone());
let normalized_title = Self::normalize_key(title.unwrap_or(slug));
if !normalized_title.is_empty() {
map.insert(normalized_title, path);
}
}
}
fn resolve_query_path(&self, query: &str) -> Option<String> {
let trimmed = query.trim().trim_start_matches('@');
if let Some((kind, raw_value)) = trimmed.split_once(':') {
let kind = kind.trim().to_ascii_lowercase();
let value = raw_value.trim().trim_matches('/').replace(' ', "-");
if !value.is_empty() && matches!(kind.as_str(), "sites" | "models") {
return Some(format!("{kind}/{value}"));
}
}
let normalized = Self::normalize_key(trimmed);
if normalized.is_empty() {
return None;
}
self.tag_map.read().ok()?.get(&normalized).cloned()
}
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 let Some(path) = self.resolve_query_path(query) {
video_url = format!("{}/{}/{}/", self.url, path, 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(
"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() {
self.insert_tag_mapping("sites", &tag, None);
tags.push(Self::humanize_slug(&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() {
self.insert_tag_mapping("models", &tag, None);
tags.push(Self::humanize_slug(&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))
}
}

1577
src/providers/omgxxx.rs Normal file

File diff suppressed because it is too large Load Diff

View 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))
}
}

View File

@@ -0,0 +1,553 @@
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::collections::HashMap;
use std::env;
use std::sync::{Arc, RwLock};
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,
tag_map: Arc<RwLock<HashMap<String, String>>>,
}
impl PerfectgirlsProvider {
pub fn new() -> Self {
PerfectgirlsProvider {
url: "https://www.perfectgirls.xxx".to_string(),
tag_map: Arc::new(RwLock::new(HashMap::new())),
}
}
fn normalize_key(value: &str) -> String {
value
.trim()
.to_ascii_lowercase()
.replace(['_', '-'], " ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn humanize_slug(value: &str) -> String {
value
.trim_matches('/')
.replace('-', " ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn insert_tag_mapping(&self, kind: &str, slug: &str, title: Option<&str>) {
let slug = slug.trim().trim_matches('/');
if slug.is_empty() {
return;
}
let path = format!("{kind}/{slug}");
if let Ok(mut map) = self.tag_map.write() {
map.insert(Self::normalize_key(slug), path.clone());
let normalized_title = Self::normalize_key(title.unwrap_or(slug));
if !normalized_title.is_empty() {
map.insert(normalized_title, path);
}
}
}
fn resolve_query_path(&self, query: &str) -> Option<String> {
let trimmed = query.trim().trim_start_matches('@');
if let Some((kind, raw_value)) = trimmed.split_once(':') {
let kind = kind.trim().to_ascii_lowercase();
let value = raw_value.trim().trim_matches('/').replace(' ', "-");
if !value.is_empty() && matches!(kind.as_str(), "channels" | "pornstars") {
return Some(format!("{kind}/{value}"));
}
}
let normalized = Self::normalize_key(trimmed);
if normalized.is_empty() {
return None;
}
self.tag_map.read().ok()?.get(&normalized).cloned()
}
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 let Some(path) = self.resolve_query_path(query) {
video_url = format!("{}/{}/{}/", self.url, path, 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(
"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() {
self.insert_tag_mapping("channels", &tag, None);
tags.push(Self::humanize_slug(&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() {
self.insert_tag_mapping("pornstars", &tag, None);
tags.push(Self::humanize_slug(&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))
}
}

View File

@@ -1,142 +1,530 @@
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 std::collections::HashMap;
use std::sync::{Arc, RwLock};
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,
tag_map: Arc<RwLock<HashMap<String, String>>>,
}
impl PerverzijaProvider {
pub fn new() -> Self {
PerverzijaProvider {
url: "https://tube.perverzija.com/".to_string(),
tag_map: Arc::new(RwLock::new(HashMap::new())),
}
}
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=&quot;", "&quot;"))
.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()
}
fn clip_at_first<'a>(haystack: &'a str, end_markers: &[&str]) -> &'a str {
let mut end = haystack.len();
for marker in end_markers {
if let Some(index) = haystack.find(marker) {
end = end.min(index);
}
}
&haystack[..end]
}
fn listing_item_scope(haystack: &str) -> &str {
Self::clip_at_first(haystack, &["</article>", "</li>", "<article ", "video-item post"])
}
fn detail_meta_section<'a>(text: &'a str, label: &str) -> &'a str {
let section = text
.split(label)
.nth(1)
.unwrap_or_default();
Self::clip_at_first(
section,
&["</div>", "</p>", "<strong>", "<div class=\"related", "<section", "<aside"],
)
}
fn push_unique(tags: &mut Vec<String>, value: String) {
let normalized = value.trim();
if normalized.is_empty() {
return;
}
if !tags
.iter()
.any(|existing| existing.eq_ignore_ascii_case(normalized))
{
tags.push(normalized.to_string());
}
}
fn parse_href_values(section: &str) -> Vec<String> {
section
.split("<a href=\"")
.skip(1)
.filter_map(|part| part.split('"').next())
.map(|value| value.to_string())
.collect()
}
fn normalize_key(value: &str) -> String {
value
.trim()
.to_ascii_lowercase()
.replace(['_', '-'], " ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn humanize_slug(value: &str) -> String {
value
.trim_matches('/')
.replace('-', " ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn insert_tag_mapping(&self, kind: &str, slug: &str, title: Option<&str>) {
let slug = slug.trim().trim_matches('/');
if slug.is_empty() {
return;
}
let path = format!("{kind}/{slug}");
if let Ok(mut map) = self.tag_map.write() {
map.insert(Self::normalize_key(slug), path.clone());
let normalized_title = Self::normalize_key(title.unwrap_or(slug));
if !normalized_title.is_empty() {
map.insert(normalized_title, path);
}
}
}
fn resolve_query_path(&self, query: &str) -> Option<String> {
let trimmed = query.trim().trim_start_matches('@');
if let Some((kind, raw_value)) = trimmed.split_once(':') {
let kind = kind.trim().to_ascii_lowercase();
let value = raw_value.trim().trim_matches('/').replace(' ', "-");
if !value.is_empty() && matches!(kind.as_str(), "studio" | "stars" | "tag" | "genre")
{
return Some(format!("{kind}/{value}"));
}
}
let normalized = Self::normalize_key(trimmed);
if normalized.is_empty() {
return None;
}
self.tag_map.read().ok()?.get(&normalized).cloned()
}
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" {
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());
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 {
return Ok(old_items);
}
Ok(video_items)
}
async fn query(
&self,
cache: VideoCache,
pool: DbPool,
page: u8,
query: &str,
options: ServerOptions,
) -> Result<Vec<VideoItem>> {
let mut query_parse = true;
let search_string = query.replace(" ", "+");
let mut url_str = format!("{}page/{}/?s={}", self.url, page, search_string);
if page == 1 {
url_str = format!("{}?s={}", self.url, search_string);
}
if let Some(path) = self.resolve_query_path(query) {
url_str = format!("{}/{}/page/{}/", self.url.trim_end_matches('/'), path, 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 {
Err("Failed to fetch data".into())
let _ = cache.check().await;
return Ok(items.clone());
}
}
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();
None => {
vec![]
}
};
println!("{}", &text);
Ok(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 {
Err("Failed to fetch data".into())
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 raw_video_segment in raw_videos {
let video_segment = Self::listing_item_scope(raw_video_segment);
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=&quot;").collect::<Vec<&str>>()[1]
.split("&quot;")
.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, ")
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();
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/") {
let slug = studio
.split("/\"")
.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");
.first()
.copied()
.unwrap_or_default()
.replace("https://tube.perverzija.com/studio/", "");
self.insert_tag_mapping("studio", &slug, None);
Self::push_unique(
&mut tags,
Self::humanize_slug(&slug),
);
}
}
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"));
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() {
self.insert_tag_mapping("stars", &tag_name, None);
Self::push_unique(&mut tags, Self::humanize_slug(&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() {
Self::push_unique(&mut tags, 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 +535,308 @@ 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();
let studios_section = Self::detail_meta_section(&text, "<strong>Studio: </strong>");
for href in Self::parse_href_values(studios_section) {
if href.starts_with("https://tube.perverzija.com/studio/") {
let studio_slug = href
.trim_end_matches('/')
.replace("https://tube.perverzija.com/studio/", "");
self.insert_tag_mapping("studio", &studio_slug, None);
Self::push_unique(&mut tags, Self::humanize_slug(&studio_slug));
}
}
let stars_section = Self::detail_meta_section(&text, "<strong>Stars: </strong>");
for href in Self::parse_href_values(stars_section) {
if href.starts_with("https://tube.perverzija.com/stars/") {
let star_slug = href
.trim_end_matches('/')
.replace("https://tube.perverzija.com/stars/", "");
self.insert_tag_mapping("stars", &star_slug, None);
Self::push_unique(&mut tags, Self::humanize_slug(&star_slug));
}
}
let tags_section = if text.contains("<strong>Tags: </strong>") {
Self::detail_meta_section(&text, "<strong>Tags: </strong>")
} else {
Self::detail_meta_section(&text, "<strong>Genres: </strong>")
};
for href in Self::parse_href_values(tags_section) {
if href.starts_with("https://tube.perverzija.com/stars/") {
let star_slug = href
.trim_end_matches('/')
.replace("https://tube.perverzija.com/stars/", "");
self.insert_tag_mapping("stars", &star_slug, None);
Self::push_unique(&mut tags, Self::humanize_slug(&star_slug));
continue;
}
if href.starts_with("https://tube.perverzija.com/tag/") {
let tag_slug = href
.trim_end_matches('/')
.replace("https://tube.perverzija.com/tag/", "");
self.insert_tag_mapping("tag", &tag_slug, None);
Self::push_unique(&mut tags, Self::humanize_slug(&tag_slug));
continue;
}
if href.starts_with("https://tube.perverzija.com/genre/") {
let genre_slug = href
.trim_end_matches('/')
.replace("https://tube.perverzija.com/genre/", "");
self.insert_tag_mapping("genre", &genre_slug, None);
Self::push_unique(&mut tags, Self::humanize_slug(&genre_slug));
}
}
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 +846,8 @@ impl Provider for PerverzijaProvider {
}
}
}
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
Some(self.build_channel(clientversion))
}
}

1070
src/providers/pimpbunny.rs Normal file

File diff suppressed because it is too large Load Diff

487
src/providers/pmvhaven.rs Normal file
View 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
View 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
View 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("&amp;", "&")
}
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

File diff suppressed because it is too large Load Diff

444
src/providers/pornhat.rs Normal file
View File

@@ -0,0 +1,444 @@
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::collections::HashMap;
use std::sync::{Arc, RwLock};
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,
tag_map: Arc<RwLock<HashMap<String, String>>>,
}
impl PornhatProvider {
pub fn new() -> Self {
PornhatProvider {
url: "https://www.pornhat.com".to_string(),
tag_map: Arc::new(RwLock::new(HashMap::new())),
}
}
fn normalize_key(value: &str) -> String {
value
.trim()
.to_ascii_lowercase()
.replace(['_', '-'], " ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn humanize_slug(value: &str) -> String {
value
.trim_matches('/')
.replace('-', " ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn insert_tag_mapping(&self, kind: &str, slug: &str, title: Option<&str>) {
let slug = slug.trim().trim_matches('/');
if slug.is_empty() {
return;
}
let path = format!("{kind}/{slug}");
if let Ok(mut map) = self.tag_map.write() {
map.insert(Self::normalize_key(slug), path.clone());
let normalized_title = Self::normalize_key(title.unwrap_or(slug));
if !normalized_title.is_empty() {
map.insert(normalized_title, path);
}
}
}
fn resolve_query_path(&self, query: &str) -> Option<String> {
let trimmed = query.trim().trim_start_matches('@');
if let Some((kind, raw_value)) = trimmed.split_once(':') {
let kind = kind.trim().to_ascii_lowercase();
let value = raw_value.trim().trim_matches('/').replace(' ', "-");
if !value.is_empty() && matches!(kind.as_str(), "sites" | "models") {
return Some(format!("{kind}/{value}"));
}
}
let normalized = Self::normalize_key(trimmed);
if normalized.is_empty() {
return None;
}
self.tag_map.read().ok()?.get(&normalized).cloned()
}
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 let Some(path) = self.resolve_query_path(query) {
video_url = format!("{}/{}/{}/", self.url, path, 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(
"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() {
self.insert_tag_mapping("sites", &tag, None);
tags.push(Self::humanize_slug(&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() {
self.insert_tag_mapping("models", &tag, None);
tags.push(Self::humanize_slug(&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

File diff suppressed because it is too large Load Diff

827
src/providers/pornhub.rs Normal file
View File

@@ -0,0 +1,827 @@
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::collections::HashMap;
use std::collections::HashSet;
use std::sync::{Arc, RwLock};
use std::thread;
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";
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,
tag_map: Arc<RwLock<HashMap<String, TagInfo>>>,
}
#[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,
}
#[derive(Debug, Clone)]
struct TagInfo {
kind: QueryTargetKind,
slug: String,
title: 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 {
let provider = Self {
url: BASE_URL.to_string(),
tag_map: Arc::new(RwLock::new(HashMap::new())),
};
provider.spawn_initial_load();
provider
}
fn spawn_initial_load(&self) {
let url = self.url.clone();
let tag_map = Arc::clone(&self.tag_map);
thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
let _ = Self::load_tags(&url, tag_map).await;
});
});
}
async fn load_tags(
base_url: &str,
tag_map: Arc<RwLock<HashMap<String, TagInfo>>>,
) -> Result<()> {
Self::load_kind(base_url, "channel", QueryTargetKind::Channel, &tag_map).await?;
Self::load_kind(base_url, "pornstar", QueryTargetKind::Pornstar, &tag_map).await?;
Self::load_kind(base_url, "model", QueryTargetKind::Model, &tag_map).await?;
Self::load_kind(base_url, "user", QueryTargetKind::User, &tag_map).await?;
Ok(())
}
async fn load_kind(
base_url: &str,
path_segment: &str,
kind: QueryTargetKind,
tag_map: &Arc<RwLock<HashMap<String, TagInfo>>>,
) -> Result<()> {
let url = format!("{}/{}/top", base_url, path_segment);
let mut requester = crate::util::requester::Requester::new();
let body = requester
.get(&url, None)
.await
.map_err(|e| Error::from(ErrorKind::Parse(format!("http request failed: {e}"))))?;
let document = Html::parse_document(&body);
let selector = Self::selector(&format!("a[href^='/{}/']", path_segment))?;
for element in document.select(&selector) {
if let Some(href) = element.attr("href") {
if let Some(slug) = Self::slug_from_url(href, path_segment) {
let title = element.text().collect::<String>().trim().to_string();
if !title.is_empty() && !slug.is_empty() {
let info = TagInfo {
kind,
slug: slug.clone(),
title: title.clone(),
};
let mut map = tag_map.write().unwrap();
map.insert(title.to_ascii_lowercase(), info.clone());
map.insert(slug.to_ascii_lowercase(), info);
}
}
}
}
Ok(())
}
fn slug_from_url(url: &str, path_segment: &str) -> Option<String> {
let parsed = Url::parse(url).ok()?;
let mut segments = parsed.path_segments()?;
if segments.next() == Some(path_segment) {
segments.next().map(|s| s.to_string())
} else {
None
}
}
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| {
Error::from(ErrorKind::Parse(format!(
"selector parse failed for {value}: {error}"
)))
})
}
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(&self, query: &str) -> Option<QueryTarget> {
let normalized = query.trim().to_ascii_lowercase();
if let Some(info) = self.tag_map.read().unwrap().get(&normalized) {
return Some(QueryTarget {
kind: info.kind,
slug: info.slug.clone(),
});
}
// Fallback to kind:slug without @
let trimmed = query.trim();
let (kind_str, 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_str.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, .views")?;
let rating_selector =
Self::selector(".value, .rating, .ratingInfo, .percent, .ratingPercent")?;
let tag_link_selector = Self::selector(
"a[href*=\"/categories/\"], a[href*=\"/video/search\"], a[href*=\"/pornstar/\"], a[href*=\"/model/\"], a[href*=\"/channels/\"], a[href*=\"/users/\"]",
)?;
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"))
.or_else(|| value.value().attr("data-path"))
.or_else(|| value.value().attr("data-src"))
})
.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).find_map(|value| {
let text = Self::text_of(&value);
parse_abbreviated_number(&text)
.or_else(|| parse_abbreviated_number(text.replace("views", "").trim()))
});
let rating = card.select(&rating_selector).find_map(|value| {
let text = Self::text_of(&value);
let cleaned = text
.trim()
.trim_end_matches('%')
.replace(',', "")
.replace(' ', "");
cleaned.parse::<f32>().ok()
});
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;
let preview_url = image
.and_then(|value| value.value().attr("data-mediabook"))
.map(|value| self.normalize_url(value))
.filter(|value| !value.is_empty());
item.preview = preview_url.clone();
if preview_url.is_some() {
let mut format = VideoFormat::new(
item.url.clone(),
"preview".to_string(),
"video/mp4".to_string(),
);
format.add_http_header("Referer".to_string(), item.url.clone());
item.formats = Some(vec![format]);
}
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);
item.rating = rating;
let mut tags = Vec::new();
if let Some(tag) = uploader_url
.as_deref()
.and_then(|url| self.query_tag_from_uploader_url(url))
{
Self::push_unique(&mut tags, tag);
}
for tag_link in card.select(&tag_link_selector) {
let tag = Self::decode_html(&Self::text_of(&tag_link));
Self::push_unique(&mut tags, 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(&self, url: &str) -> Option<String> {
let parsed = Url::parse(url).ok()?;
let mut segments = parsed.path_segments()?;
let kind_str = segments.next()?.trim_matches('/');
let slug = segments.next()?.trim_matches('/');
if kind_str.is_empty() || slug.is_empty() {
return None;
}
let normalized_slug = slug.to_ascii_lowercase();
if let Some(info) = self.tag_map.read().unwrap().get(&normalized_slug) {
return Some(info.title.clone());
}
Some(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());
}
async fn fetch_listing(
&self,
cache: VideoCache,
page: u8,
sort: &str,
query: Option<&str>,
options: ServerOptions,
) -> Result<Vec<VideoItem>> {
if query.is_some() && self.tag_map.read().unwrap().is_empty() {
let _ = Self::load_tags(&self.url, Arc::clone(&self.tag_map)).await;
}
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);
}
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 provider = PornhubProvider::new();
let target = provider
.parse_query_target("channels:Brazzers")
.expect("channel target should parse");
assert!(matches!(target.kind, QueryTargetKind::Channel));
assert_eq!(target.slug, "brazzers");
let target = provider
.parse_query_target("pornstar:Alex Mack")
.expect("pornstar target should parse");
assert!(matches!(target.kind, QueryTargetKind::Pornstar));
assert_eq!(target.slug, "alex-mack");
assert!(provider.parse_query_target("teacher").is_none());
}
#[test]
fn resolves_query_from_tag_map_by_id_or_title() {
let provider = PornhubProvider::new();
{
let mut map = provider.tag_map.write().unwrap();
let info = TagInfo {
kind: QueryTargetKind::Channel,
slug: "mature-4k".to_string(),
title: "Mature 4K".to_string(),
};
map.insert("mature-4k".to_string(), info.clone());
map.insert("mature 4k".to_string(), info);
}
let by_id = provider
.parse_query_target("mature-4k")
.expect("id lookup should resolve");
assert!(matches!(by_id.kind, QueryTargetKind::Channel));
assert_eq!(by_id.slug, "mature-4k");
let by_title = provider
.parse_query_target("Mature 4K")
.expect("title lookup should resolve");
assert!(matches!(by_title.kind, QueryTargetKind::Channel));
assert_eq!(by_title.slug, "mature-4k");
}
#[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.eq_ignore_ascii_case("honeycore"))
}));
}
#[test]
fn parses_listing_metadata_without_detail_fetch() {
let provider = PornhubProvider::new();
let html = r#"
<ul id="videoCategory" class="nf-videos videos search-video-thumbs">
<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 data-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="videoDetailsBlock">
<span class="views"><var>199K</var> views</span>
<span class="value">95%</span>
</div>
<a href="/categories/anal">Anal</a>
<a href="/pornstar/jane-doe">Jane Doe</a>
</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].thumb, "https://example.com/thumb.jpg");
assert_eq!(
items[0].preview.as_deref(),
Some("https://example.com/preview.webm")
);
assert_eq!(items[0].views, Some(199000));
assert_eq!(items[0].rating, Some(95.0));
assert!(
items[0]
.tags
.as_ref()
.is_some_and(|values| values.iter().any(|value| value == "Anal"))
);
assert!(
items[0]
.tags
.as_ref()
.is_some_and(|values| values.iter().any(|value| value == "Jane Doe"))
);
}
}

1248
src/providers/pornmz.rs Normal file

File diff suppressed because it is too large Load Diff

987
src/providers/porntrex.rs Normal file
View File

@@ -0,0 +1,987 @@
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::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::{self, StreamExt};
use htmlentity::entity::{ICodedDataTrait, decode};
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use regex::Regex;
use scraper::{ElementRef, Html, Selector};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use std::{thread, vec};
use wreq::Version;
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
crate::providers::ProviderChannelMetadata {
group_id: "mainstream-tube",
tags: &["tube", "mixed", "hd"],
};
const BASE_URL: &str = "https://www.porntrex.com";
const CHANNEL_ID: &str = "porntrex";
const FIREFOX_UA: &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";
error_chain! {
foreign_links {
Io(std::io::Error);
}
errors {
Parse(msg: String) {
description("parse error")
display("parse error: {}", msg)
}
}
}
#[derive(Debug, Clone)]
pub struct PorntrexProvider {
url: String,
categories: Arc<RwLock<Vec<FilterOption>>>,
tag_map: Arc<RwLock<HashMap<String, String>>>,
}
#[derive(Debug, Clone)]
enum Target {
Latest,
Popular,
TopRated,
Search(String),
Archive {
url: String,
page_mode: PageMode,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PageMode {
SitePaged,
LocalSlice,
}
impl PorntrexProvider {
pub fn new() -> Self {
let provider = Self {
url: BASE_URL.to_string(),
categories: Arc::new(RwLock::new(vec![FilterOption {
id: "all".to_string(),
title: "All".to_string(),
}])),
tag_map: Arc::new(RwLock::new(HashMap::new())),
};
provider.spawn_initial_load();
provider
}
fn spawn_initial_load(&self) {
let url = self.url.clone();
let categories = Arc::clone(&self.categories);
let tag_map = Arc::clone(&self.tag_map);
thread::spawn(move || {
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(error) => {
report_provider_error_background(
CHANNEL_ID,
"spawn_initial_load.runtime_build",
&error.to_string(),
);
return;
}
};
runtime.block_on(async move {
if let Err(error) = Self::load_categories(&url, Arc::clone(&categories)).await {
report_provider_error_background(
CHANNEL_ID,
"load_categories",
&error.to_string(),
);
}
if let Err(error) = Self::load_tags(&url, Arc::clone(&tag_map)).await {
report_provider_error_background(CHANNEL_ID, "load_tags", &error.to_string());
}
});
});
}
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
let categories = self
.categories
.read()
.map(|value| value.clone())
.unwrap_or_default();
Channel {
id: CHANNEL_ID.to_string(),
name: "PornTrex".to_string(),
description:
"PornTrex videos with latest, most viewed, top rated, category, and tag-aware search routing."
.to_string(),
premium: false,
favicon: "https://www.google.com/s2/favicons?sz=64&domain=porntrex.com".to_string(),
status: "active".to_string(),
categories: categories.iter().map(|value| value.title.clone()).collect(),
options: vec![
ChannelOption {
id: "sort".to_string(),
title: "Sort".to_string(),
description: "Browse PornTrex ranking feeds.".to_string(),
systemImage: "list.number".to_string(),
colorName: "blue".to_string(),
options: vec![
FilterOption {
id: "new".to_string(),
title: "Latest".to_string(),
},
FilterOption {
id: "popular".to_string(),
title: "Most Viewed".to_string(),
},
FilterOption {
id: "rated".to_string(),
title: "Top Rated".to_string(),
},
],
multiSelect: false,
},
ChannelOption {
id: "categories".to_string(),
title: "Categories".to_string(),
description: "Browse a PornTrex category archive.".to_string(),
systemImage: "square.grid.2x2".to_string(),
colorName: "orange".to_string(),
options: categories,
multiSelect: false,
},
],
nsfw: true,
cacheDuration: Some(1800),
}
}
fn selector(value: &str) -> Result<Selector> {
Selector::parse(value)
.map_err(|error| Error::from(format!("selector `{value}` parse failed: {error}")))
}
fn regex(value: &str) -> Result<Regex> {
Regex::new(value).map_err(|error| Error::from(format!("regex `{value}` failed: {error}")))
}
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::decode_html(&Self::collapse_whitespace(
&element.text().collect::<Vec<_>>().join(" "),
))
}
fn normalize_title(title: &str) -> String {
title
.trim()
.trim_start_matches('#')
.replace(['_', '-'], " ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_ascii_lowercase()
}
fn normalize_url(&self, url: &str) -> String {
let trimmed = url.trim();
if trimmed.is_empty() {
return String::new();
}
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
return trimmed.to_string();
}
if trimmed.starts_with("//") {
return format!("https:{trimmed}");
}
if trimmed.starts_with('/') {
return format!("{}{}", self.url, trimmed);
}
format!("{}/{}", self.url, trimmed.trim_start_matches("./"))
}
fn html_headers(referer: &str) -> Vec<(String, String)> {
vec![
("User-Agent".to_string(), FIREFOX_UA.to_string()),
("Accept".to_string(), HTML_ACCEPT.to_string()),
("Referer".to_string(), referer.to_string()),
]
}
fn build_search_path(query: &str) -> String {
query
.split_whitespace()
.map(|part| utf8_percent_encode(part, NON_ALPHANUMERIC).to_string())
.collect::<Vec<_>>()
.join("-")
}
fn build_archive_page_url(archive_url: &str, page: u16) -> String {
if page <= 1 {
return archive_url.trim_end_matches('/').to_string() + "/";
}
format!("{}/{page}/", archive_url.trim_end_matches('/'))
}
fn archive_target(url: String, page_mode: PageMode) -> Target {
Target::Archive { url, page_mode }
}
fn build_target_url(&self, target: &Target, page: u16) -> String {
match target {
Target::Latest => {
Self::build_archive_page_url(&format!("{}/latest-updates/", self.url), page)
}
Target::Popular => {
Self::build_archive_page_url(&format!("{}/most-popular/", self.url), page)
}
Target::TopRated => {
Self::build_archive_page_url(&format!("{}/top-rated/", self.url), page)
}
Target::Search(query) => Self::build_archive_page_url(
&format!("{}/search/{}/", self.url, Self::build_search_path(query)),
page,
),
Target::Archive { url, page_mode } => match page_mode {
PageMode::SitePaged => Self::build_archive_page_url(url, page),
PageMode::LocalSlice => Self::build_archive_page_url(url, 1),
},
}
}
async fn fetch_html(requester: &mut Requester, url: &str, referer: &str) -> Result<String> {
requester
.get_with_headers(url, Self::html_headers(referer), Some(Version::HTTP_11))
.await
.map_err(|error| Error::from(format!("request failed for {url}: {error}")))
}
fn slug_remainder(href: &str, prefix: &str) -> Option<String> {
let trimmed = href.trim().trim_end_matches('/');
let remainder = trimmed.strip_prefix(prefix)?.trim_matches('/');
if remainder.is_empty() || remainder.contains('/') {
return None;
}
Some(remainder.to_string())
}
fn push_category(target: &Arc<RwLock<Vec<FilterOption>>>, item: FilterOption) {
if item.id.is_empty() || item.title.is_empty() {
return;
}
if let Ok(mut values) = target.write() {
let normalized = Self::normalize_title(&item.title);
if !values
.iter()
.any(|value| value.id == item.id || Self::normalize_title(&value.title) == normalized)
{
values.push(item);
}
}
}
fn insert_tag_mapping(target: &Arc<RwLock<HashMap<String, String>>>, title: &str, href: &str) {
let normalized_title = Self::normalize_title(title);
if normalized_title.is_empty() || href.is_empty() {
return;
}
if let Ok(mut values) = target.write() {
values.insert(normalized_title, href.to_string());
}
}
async fn load_categories(
base_url: &str,
categories: Arc<RwLock<Vec<FilterOption>>>,
) -> Result<()> {
let mut requester = Requester::new();
let page_url = format!("{base_url}/categories/");
let html = Self::fetch_html(&mut requester, &page_url, &page_url).await?;
let document = Html::parse_document(&html);
let selector = Self::selector("a.item[href]")?;
let prefix = format!("{base_url}/categories/");
for element in document.select(&selector) {
let href = element.value().attr("href").unwrap_or_default();
let Some(_slug) = Self::slug_remainder(href, &prefix) else {
continue;
};
let title = element
.value()
.attr("title")
.map(Self::decode_html)
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| Self::text_of(&element));
let title = title.trim().to_string();
if title.is_empty() {
continue;
}
Self::push_category(
&categories,
FilterOption {
id: format!("{}/", href.trim_end_matches('/')),
title,
},
);
}
Ok(())
}
async fn load_tags(base_url: &str, tag_map: Arc<RwLock<HashMap<String, String>>>) -> Result<()> {
let mut requester = Requester::new();
let page_url = format!("{base_url}/tags/");
let html = Self::fetch_html(&mut requester, &page_url, &page_url).await?;
let document = Html::parse_document(&html);
let selector = Self::selector("div.list-tags a[href]")?;
let prefix = format!("{base_url}/tags/");
for element in document.select(&selector) {
let href = element.value().attr("href").unwrap_or_default();
let Some(slug) = Self::slug_remainder(href, &prefix) else {
continue;
};
let title = Self::text_of(&element);
if title.is_empty() {
continue;
}
let canonical = format!("{}/", href.trim_end_matches('/'));
Self::insert_tag_mapping(&tag_map, &title, &canonical);
Self::insert_tag_mapping(&tag_map, &slug, &canonical);
}
Ok(())
}
fn parse_duration(text: &str) -> u32 {
parse_time_to_seconds(text)
.and_then(|value| u32::try_from(value).ok())
.unwrap_or(0)
}
fn parse_views(text: &str) -> Option<u32> {
let cleaned = text
.replace("views", "")
.replace("view", "")
.replace([',', ' '], "");
parse_abbreviated_number(cleaned.trim())
}
fn parse_rating(text: &str) -> Option<f32> {
let digits = text
.chars()
.filter(|value| value.is_ascii_digit() || *value == '.')
.collect::<String>();
digits.parse::<f32>().ok()
}
fn parse_list_videos(&self, html: &str) -> Result<Vec<VideoItem>> {
let document = Html::parse_document(html);
let card_selector = Self::selector("div.video-preview-screen.video-item.thumb-item")?;
let link_selector = Self::selector("a[href*=\"/video/\"]")?;
let title_link_selector = Self::selector("p.inf a[href*=\"/video/\"], a[title][href*=\"/video/\"]")?;
let image_selector = Self::selector("img.cover")?;
let duration_selector = Self::selector("div.durations")?;
let views_selector = Self::selector("div.viewsthumb")?;
let rating_selector = Self::selector("ul.list-unstyled li.pull-right")?;
let mut items = Vec::new();
for card in document.select(&card_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() {
continue;
}
let id = card
.value()
.attr("data-item-id")
.map(str::to_string)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| {
page_url
.trim_end_matches('/')
.split('/')
.nth_back(1)
.unwrap_or_default()
.to_string()
});
if id.is_empty() {
continue;
}
let image = card.select(&image_selector).next();
let thumb = image
.and_then(|value| value.value().attr("data-src").or_else(|| value.value().attr("src")))
.map(|value| self.normalize_url(value))
.unwrap_or_default();
let title = card
.select(&title_link_selector)
.next()
.or_else(|| card.select(&link_selector).find(|value| value.value().attr("title").is_some()))
.and_then(|value| value.value().attr("title").map(Self::decode_html).or_else(|| {
let text = Self::text_of(&value);
(!text.is_empty()).then_some(text)
}))
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| {
image
.and_then(|value| value.value().attr("alt").map(Self::decode_html))
.unwrap_or_default()
});
if title.is_empty() {
continue;
}
let duration = card
.select(&duration_selector)
.next()
.map(|value| Self::parse_duration(&Self::text_of(&value)))
.unwrap_or(0);
let views = card
.select(&views_selector)
.next()
.and_then(|value| Self::parse_views(&Self::text_of(&value)));
let rating = card
.select(&rating_selector)
.next()
.and_then(|value| Self::parse_rating(&Self::text_of(&value)));
let mut item = VideoItem::new(
id,
title.trim().to_string(),
page_url,
CHANNEL_ID.to_string(),
thumb,
duration,
);
item.views = views;
item.rating = rating;
items.push(item);
}
Ok(items)
}
fn parse_format_urls(html: &str) -> Result<Vec<VideoFormat>> {
let pairs = [
("video_url", "video_url_text"),
("video_alt_url", "video_alt_url_text"),
("video_alt_url2", "video_alt_url2_text"),
("video_alt_url3", "video_alt_url3_text"),
];
let mut formats = Vec::new();
let mut seen = std::collections::HashSet::new();
for (url_key, label_key) in pairs {
let url_re = Self::regex(&format!(r#"{url_key}:\s*'([^']+)'"#))?;
let label_re = Self::regex(&format!(r#"{label_key}:\s*'([^']*)'"#))?;
let Some(url_match) = url_re.captures(html).and_then(|value| value.get(1)) else {
continue;
};
let url = url_match.as_str().replace("\\/", "/");
if !seen.insert(url.clone()) {
continue;
}
let label = label_re
.captures(html)
.and_then(|value| value.get(1))
.map(|value| value.as_str().trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "mp4".to_string());
let normalized_label = label.replace(" HD", "").replace(" FHD", "").trim().to_string();
let format = VideoFormat::new(url, normalized_label.clone(), "mp4".to_string())
.format_id(normalized_label.clone())
.format_note(label);
formats.push(format);
}
Ok(formats)
}
fn parse_aspect_ratio(html: &str) -> Result<Option<f32>> {
let width_re = Self::regex(r#"player_width:\s*'([0-9.]+)'"#)?;
let height_re = Self::regex(r#"player_height:\s*'([0-9.]+)'"#)?;
let Some(width) = width_re
.captures(html)
.and_then(|value| value.get(1))
.and_then(|value| value.as_str().parse::<f32>().ok())
else {
return Ok(None);
};
let Some(height) = height_re
.captures(html)
.and_then(|value| value.get(1))
.and_then(|value| value.as_str().parse::<f32>().ok())
else {
return Ok(None);
};
Ok((height > 0.0).then_some(width / height))
}
fn collect_tags(document: &Html, html: &str) -> Result<Vec<String>> {
let category_selector = Self::selector("div.items-holder.js-categories a[href*=\"/categories/\"]")?;
let tag_selector = Self::selector("div.item a[href*=\"/tags/\"]")?;
let mut values = Vec::new();
for element in document.select(&category_selector) {
let value = Self::text_of(&element);
if !value.is_empty() {
values.push(value);
}
}
for element in document.select(&tag_selector) {
let value = Self::text_of(&element);
if !value.is_empty() {
values.push(value);
}
}
for pattern in [r#"video_categories:\s*'([^']*)'"#, r#"video_tags:\s*'([^']*)'"#] {
let re = Self::regex(pattern)?;
if let Some(raw) = re.captures(html).and_then(|value| value.get(1)) {
for entry in raw
.as_str()
.split(',')
.map(str::trim)
.map(Self::decode_html)
.filter(|value| !value.is_empty())
{
values.push(entry);
}
}
}
let mut unique = Vec::new();
for value in values {
let normalized = Self::normalize_title(&value);
if normalized.is_empty() || normalized == "-" {
continue;
}
if !unique
.iter()
.any(|existing: &String| Self::normalize_title(existing) == normalized)
{
unique.push(value);
}
}
Ok(unique)
}
fn apply_detail_video(&self, mut item: VideoItem, html: &str) -> Result<VideoItem> {
let document = Html::parse_document(html);
let title_selector = Self::selector("h1")?;
let uploader_selector =
Self::selector("div.info-block div.block-user div.username a[href*=\"/members/\"]")?;
let stat_selector = Self::selector("div.info-block div.item span")?;
if let Some(title) = document
.select(&title_selector)
.next()
.map(|value| Self::text_of(&value))
.filter(|value| !value.is_empty())
{
item.title = title;
}
if let Some(uploader) = document.select(&uploader_selector).next() {
let uploader_name = Self::text_of(&uploader);
let uploader_url = uploader
.value()
.attr("href")
.map(|value| self.normalize_url(value))
.unwrap_or_default();
if !uploader_name.is_empty() {
item.uploader = Some(uploader_name);
}
if !uploader_url.is_empty() {
let uploader_id = uploader_url
.trim_end_matches('/')
.split('/')
.next_back()
.unwrap_or_default()
.to_string();
item.uploaderUrl = Some(uploader_url);
if !uploader_id.is_empty() {
item.uploaderId = Some(format!("{CHANNEL_ID}:{uploader_id}"));
}
}
}
for stat in document.select(&stat_selector).map(|value| Self::text_of(&value)) {
if item.views.is_none() {
item.views = Self::parse_views(&stat);
}
if item.duration == 0 {
let duration = Self::parse_duration(&stat);
if duration > 0 {
item.duration = duration;
}
}
}
let tags = Self::collect_tags(&document, html)?;
if !tags.is_empty() {
item.tags = Some(tags);
}
// let formats = Self::parse_format_urls(html)?;
// if !formats.is_empty() {
// item.formats = Some(formats);
// }
if item.aspectRatio.is_none() {
item.aspectRatio = Self::parse_aspect_ratio(html)?;
}
Ok(item)
}
async fn enrich_item(&self, item: VideoItem, options: &ServerOptions) -> VideoItem {
let mut requester =
requester_or_default(options, CHANNEL_ID, "porntrex.enrich_item.missing_requester");
match Self::fetch_html(&mut requester, &item.url, &item.url).await {
Ok(html) => match self.apply_detail_video(item.clone(), &html) {
Ok(value) => value,
Err(error) => {
report_provider_error_background(
CHANNEL_ID,
"enrich_item.apply_detail_video",
&format!("url={}; error={error}", item.url),
);
item
}
},
Err(error) => {
report_provider_error_background(
CHANNEL_ID,
"enrich_item.fetch_html",
&format!("url={}; error={error}", item.url),
);
item
}
}
}
fn resolve_sort_target(sort: &str) -> Target {
match sort.trim().to_ascii_lowercase().as_str() {
"popular" | "viewed" | "most_viewed" => Target::Popular,
"rated" | "rating" | "top" => Target::TopRated,
_ => Target::Latest,
}
}
fn resolve_option_target(&self, options: &ServerOptions, sort: &str) -> Target {
if let Some(category) = options.categories.as_deref() {
if category.starts_with(&self.url) && category != "all" {
return Self::archive_target(category.to_string(), PageMode::SitePaged);
}
}
Self::resolve_sort_target(sort)
}
fn lookup_category_target(&self, query: &str) -> Option<String> {
let normalized_query = Self::normalize_title(query);
self.categories
.read()
.ok()?
.iter()
.find(|value| value.id != "all" && Self::normalize_title(&value.title) == normalized_query)
.map(|value| value.id.clone())
}
fn resolve_query_target(&self, query: &str) -> Target {
let trimmed = query.trim().trim_start_matches('@');
if let Some((kind, raw_value)) = trimmed.split_once(':') {
let value = raw_value.trim().trim_matches('/').replace(' ', "-");
if !value.is_empty() {
match kind.trim().to_ascii_lowercase().as_str() {
"tag" | "tags" => {
return Self::archive_target(
format!("{}/tags/{value}/", self.url),
PageMode::LocalSlice,
);
}
"category" | "categories" => {
return Self::archive_target(
format!("{}/categories/{value}/", self.url),
PageMode::SitePaged,
);
}
_ => {}
}
}
}
if let Some(category) = self.lookup_category_target(trimmed) {
return Self::archive_target(category, PageMode::SitePaged);
}
let normalized = Self::normalize_title(trimmed);
if let Some(target) = self
.tag_map
.read()
.ok()
.and_then(|value| value.get(&normalized).cloned())
{
return Self::archive_target(target, PageMode::LocalSlice);
}
Target::Search(trimmed.to_string())
}
fn catalogs_need_refresh(&self) -> bool {
let categories_len = self
.categories
.read()
.map(|value| value.len())
.unwrap_or_default();
let tag_count = self
.tag_map
.read()
.map(|value| value.len())
.unwrap_or_default();
categories_len <= 1 || tag_count == 0
}
async fn refresh_catalogs(&self) {
if let Err(error) = Self::load_categories(&self.url, Arc::clone(&self.categories)).await {
report_provider_error_background(
CHANNEL_ID,
"refresh_catalogs.categories",
&error.to_string(),
);
}
if let Err(error) = Self::load_tags(&self.url, Arc::clone(&self.tag_map)).await {
report_provider_error_background(
CHANNEL_ID,
"refresh_catalogs.tags",
&error.to_string(),
);
}
}
async fn fetch_target(
&self,
cache: VideoCache,
target: Target,
page: u16,
per_page_limit: usize,
options: ServerOptions,
) -> Result<Vec<VideoItem>> {
let page_mode = match &target {
Target::Archive { page_mode, .. } => *page_mode,
_ => PageMode::SitePaged,
};
let source_url = self.build_target_url(&target, page);
let cache_key = match page_mode {
PageMode::SitePaged => source_url.clone(),
PageMode::LocalSlice => format!("{source_url}#page={page}&per_page={per_page_limit}"),
};
let old_items = match cache.get(&cache_key) {
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, CHANNEL_ID, "porntrex.fetch_target.missing_requester");
let html = match Self::fetch_html(&mut requester, &source_url, &source_url).await {
Ok(value) => value,
Err(error) => {
report_provider_error(
CHANNEL_ID,
"fetch_target.request",
&format!("url={source_url}; error={error}"),
)
.await;
return Ok(old_items);
}
};
if html.trim().is_empty() {
report_provider_error(
CHANNEL_ID,
"fetch_target.empty_response",
&format!("url={source_url}"),
)
.await;
return Ok(old_items);
}
let items = self.parse_list_videos(&html)?;
if items.is_empty() {
return Ok(old_items);
}
let limited_items = match page_mode {
PageMode::SitePaged => items
.into_iter()
.take(per_page_limit.max(1))
.collect::<Vec<_>>(),
PageMode::LocalSlice => {
let start = page.saturating_sub(1) as usize * per_page_limit.max(1);
items.into_iter()
.skip(start)
.take(per_page_limit.max(1))
.collect::<Vec<_>>()
}
};
if limited_items.is_empty() {
cache.insert(cache_key, vec![]);
return Ok(vec![]);
}
let enriched = stream::iter(limited_items.into_iter().map(|item| {
let provider = self.clone();
let options = options.clone();
async move { provider.enrich_item(item, &options).await }
}))
.buffer_unordered(4)
.collect::<Vec<_>>()
.await;
cache.remove(&cache_key);
cache.insert(cache_key, enriched.clone());
Ok(enriched)
}
}
#[async_trait]
impl Provider for PorntrexProvider {
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::<u16>().unwrap_or(1).max(1);
let per_page_limit = per_page.parse::<usize>().unwrap_or(10).clamp(1, 60);
let target = match query {
Some(query) if !query.trim().is_empty() => {
let query = query.trim();
let mut target = self.resolve_query_target(query);
if matches!(target, Target::Search(_)) && self.catalogs_need_refresh() {
self.refresh_catalogs().await;
target = self.resolve_query_target(query);
}
target
}
_ => self.resolve_option_target(&options, &sort),
};
match self
.fetch_target(cache, target, page, per_page_limit, options.clone())
.await
{
Ok(items) => items,
Err(error) => {
report_provider_error(
CHANNEL_ID,
"get_videos.fetch_target",
&format!("sort={sort}; page={page}; error={error}"),
)
.await;
vec![]
}
}
}
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
Some(self.build_channel(clientversion))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builds_search_url() {
let provider = PorntrexProvider::new();
assert_eq!(
provider.build_target_url(&Target::Search("adriana chechik".to_string()), 2),
"https://www.porntrex.com/search/adriana-chechik/2/"
);
}
#[test]
fn resolves_tag_prefix() {
let provider = PorntrexProvider::new();
match provider.resolve_query_target("tag:blowjob") {
Target::Archive { url, page_mode } => {
assert_eq!(url, "https://www.porntrex.com/tags/blowjob/");
assert_eq!(page_mode, PageMode::LocalSlice);
}
_ => panic!("expected archive target"),
}
}
#[test]
fn builds_local_slice_archive_url_without_numeric_page() {
let provider = PorntrexProvider::new();
let target = PorntrexProvider::archive_target(
"https://www.porntrex.com/tags/anal-creampie/".to_string(),
PageMode::LocalSlice,
);
assert_eq!(
provider.build_target_url(&target, 3),
"https://www.porntrex.com/tags/anal-creampie/"
);
}
}

275
src/providers/pornxp.rs Normal file
View 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::time::parse_time_to_seconds;
use crate::videos::{ServerOptions, VideoItem};
use async_trait::async_trait;
use error_chain::error_chain;
use htmlentity::entity::{ICodedDataTrait, decode};
use std::vec;
error_chain! {
foreign_links {
Io(std::io::Error);
HttpRequest(wreq::Error);
}
}
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
crate::providers::ProviderChannelMetadata {
group_id: "mainstream-tube",
tags: &["tube", "mainstream"],
};
#[derive(Debug, Clone)]
pub struct PornxpProvider {
url: String,
}
impl PornxpProvider {
pub fn new() -> Self {
let provider = PornxpProvider {
url: "https://pornxp.ph".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.ph".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!("{}{}", self.url,video_segment.split("<img ").collect::<Vec<&str>>()[1]
.split("data-src=\"").collect::<Vec<&str>>()[1]
.split("\"")
.collect::<Vec<&str>>()[0]
.to_string()),
false => format!("{}{}", self.url, 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))
}
}

275
src/providers/pornzog.rs Normal file
View 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))
}
}

416
src/providers/redtube.rs Normal file
View File

@@ -0,0 +1,416 @@
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 extract_between<'a>(&self, text: &'a str, start: &str, end: &str) -> Option<&'a str> {
let start_idx = text.find(start)?;
let from = start_idx + start.len();
let rest = &text[from..];
let end_idx = rest.find(end)?;
Some(&rest[..end_idx])
}
fn parse_video_grid_items(&self, html: &str) -> Vec<VideoItem> {
if !html.contains("videos_grid") {
return vec![];
}
let listing = html
.split("videos_grid")
.nth(1)
.unwrap_or_default()
.split("</ul>")
.next()
.unwrap_or_default();
let mut items: Vec<VideoItem> = Vec::new();
for li in listing.split("<li id=\"").skip(1) {
let id = self
.extract_between(li, "data-video-id=\"", "\"")
.unwrap_or_default()
.trim()
.to_string();
if id.is_empty() {
continue;
}
let title = li
.split("video-title-wrapper")
.nth(1)
.and_then(|part| self.extract_between(part, "title=\"", "\""))
.or_else(|| {
li.split("class=\"video-title-text")
.nth(1)
.and_then(|part| self.extract_between(part, "title=\"", "\""))
})
.or_else(|| self.extract_between(li, "<a title=\"", "\""))
.unwrap_or_default()
.trim()
.to_string();
let title = decode(title.as_bytes()).to_string().unwrap_or(title);
let thumb = self
.extract_between(li, "data-src=\"", "\"")
.or_else(|| self.extract_between(li, "data-o_thumb=\"", "\""))
.unwrap_or_default()
.replace("&amp;", "&");
let raw_duration = self
.extract_between(li, "<span class=\"video-properties tm_video_duration\">", "</span>")
.unwrap_or_default()
.trim()
.to_string();
let duration = parse_time_to_seconds(&raw_duration).unwrap_or(0) as u32;
let views_str = self
.extract_between(li, "<span class='info-views'>", "</span>")
.unwrap_or_default()
.trim()
.to_string();
let views = parse_abbreviated_number(&views_str).unwrap_or(0) as u32;
let preview = self
.extract_between(li, "data-mediabook=\"", "\"")
.unwrap_or_default()
.replace("&amp;", "&");
let video_url = format!("{}/{}", self.url, id);
let video_item =
VideoItem::new(id, title, video_url, "redtube".to_string(), thumb, duration)
.views(views)
.preview(preview);
items.push(video_item);
}
items
}
fn get_video_items_from_html(&self, html: String) -> Vec<VideoItem> {
if html.is_empty() {
println!("HTML is empty");
return vec![];
}
let card_items = self.parse_video_grid_items(&html);
if !card_items.is_empty() {
return card_items;
}
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![];
}
self.parse_video_grid_items(&html)
}
}
#[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))
}
}
#[cfg(test)]
mod tests {
use super::RedtubeProvider;
#[test]
fn parse_video_grid_items_handles_browse_cards() {
let provider = RedtubeProvider::new();
let html = r#"
<ul id="block_browse" class="videos_grid">
<li id="browse_195840661" data-video-id="195840661">
<a data-testid="plw_video_thumbnail_link" href="/195840661" data-video-id="195840661">
<img data-src="https://cdn.example/thumb.jpg" data-mediabook="https://cdn.example/preview.mp4?x=1&amp;y=2">
</a>
<a class="video-title-text js-pop tm_video_title " title="Stepmoms &amp; More"></a>
<span class="video-properties tm_video_duration">2:17:57</span>
<span class='info-views'>981K</span>
</li>
</ul>
"#;
let items = provider.parse_video_grid_items(html);
assert_eq!(items.len(), 1);
assert_eq!(items[0].id, "195840661");
assert_eq!(items[0].title, "Stepmoms & More");
assert_eq!(items[0].url, "https://www.redtube.com/195840661");
assert_eq!(items[0].thumb, "https://cdn.example/thumb.jpg");
assert_eq!(
items[0].preview.as_deref(),
Some("https://cdn.example/preview.mp4?x=1&y=2")
);
assert_eq!(items[0].duration, 8277);
assert_eq!(items[0].views, Some(981000));
}
#[test]
fn parse_video_grid_items_handles_tags_cards() {
let provider = RedtubeProvider::new();
let html = r#"
<div><ul class="videos_grid">
<li id="tags_videos_42785231" data-video-id="42785231">
<a data-testid="plw_video_thumbnail_link" href="/42785231" data-video-id="42785231">
<img data-o_thumb="https://cdn.example/thumb2.jpg" data-mediabook="https://cdn.example/p2.mp4">
</a>
<a class="video-title-text js-pop tm_video_title " title="Title 2"></a>
<span class="video-properties tm_video_duration">13:06</span>
<span class='info-views'>51.2K</span>
</li>
</ul></div>
"#;
let items = provider.parse_video_grid_items(html);
assert_eq!(items.len(), 1);
assert_eq!(items[0].id, "42785231");
assert_eq!(items[0].url, "https://www.redtube.com/42785231");
assert_eq!(items[0].thumb, "https://cdn.example/thumb2.jpg");
assert_eq!(items[0].duration, 786);
assert_eq!(items[0].views, Some(51200));
}
}

482
src/providers/rule34gen.rs Normal file
View 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))
}
}

View 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

File diff suppressed because it is too large Load Diff

1436
src/providers/shooshtime.rs Normal file

File diff suppressed because it is too large Load Diff

1134
src/providers/spankbang.rs Normal file

File diff suppressed because it is too large Load Diff

1958
src/providers/supjav.rs Normal file

File diff suppressed because it is too large Load Diff

498
src/providers/sxyprn.rs Normal file
View 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
View 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))
}
}

View 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);
}
}

View File

@@ -0,0 +1,679 @@
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 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 normalize_video_item_url(&self, url: &str) -> String {
let normalized = self.normalize_url(url);
if normalized.contains("/videos/") {
return normalized.replacen("/videos/", "/video/", 1);
}
normalized
}
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_video_item_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() {
item = item.preview(preview);
}
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_video_item_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() {
item = item.preview(preview);
}
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 &amp; 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/video/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));
assert!(items[0].formats.is_none());
}
#[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));
assert!(items[0].formats.is_none());
}
#[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));
}
}

1747
src/providers/vjav.rs Normal file

File diff suppressed because it is too large Load Diff

1249
src/providers/vrporn.rs Normal file

File diff suppressed because it is too large Load Diff

342
src/providers/xfree.rs Normal file
View 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
View 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))
}
}

373
src/providers/xxthots.rs Normal file
View File

@@ -0,0 +1,373 @@
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() {
video_item = video_item.preview(preview);
}
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))
}
}

1518
src/providers/yesporn.rs Normal file

File diff suppressed because it is too large Load Diff

363
src/providers/youjizz.rs Normal file
View 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>&nbsp;")
.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))
}
}

321
src/proxies/archivebate.rs Normal file
View File

@@ -0,0 +1,321 @@
use std::time::Duration as StdDuration;
use ntex::web;
use regex::Regex;
use scraper::{Html, Selector};
use url::Url;
use wreq::Version;
use crate::util::requester::Requester;
const FIREFOX_UA: &str =
"Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0";
#[derive(Debug, Clone)]
pub struct ArchivebateProxy {}
impl ArchivebateProxy {
pub fn new() -> Self {
Self {}
}
fn normalize_detail_request(endpoint: &str) -> Option<String> {
let endpoint = endpoint.trim().trim_start_matches('/');
if endpoint.is_empty() {
return None;
}
let detail_url = if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
endpoint.to_string()
} else {
format!("https://{}", endpoint.trim_start_matches('/'))
};
Self::is_allowed_detail_url(&detail_url).then_some(detail_url)
}
fn is_allowed_detail_url(url: &str) -> bool {
let Some(parsed) = Url::parse(url).ok() else {
return false;
};
if parsed.scheme() != "https" {
return false;
}
let Some(host) = parsed.host_str() else {
return false;
};
(host == "archivebate.com" || host == "www.archivebate.com")
&& parsed.path().starts_with("/watch/")
}
fn host_from_url(url: &str) -> Option<String> {
let parsed = Url::parse(url).ok()?;
parsed.host_str().map(|value| value.to_ascii_lowercase())
}
fn is_mixdrop_host(url: &str) -> bool {
let Some(host) = Self::host_from_url(url) else {
return false;
};
host.contains("mixdrop") || host.contains("m1xdrop")
}
fn html_headers(referer: &str) -> Vec<(String, String)> {
vec![
("Referer".to_string(), referer.to_string()),
("User-Agent".to_string(), FIREFOX_UA.to_string()),
(
"Accept".to_string(),
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8"
.to_string(),
),
("Accept-Language".to_string(), "en-US,en;q=0.9".to_string()),
]
}
fn first_iframe_source_from_html(html: &str) -> Option<String> {
let document = Html::parse_document(html);
let selector = Selector::parse("iframe[src]").ok()?;
document
.select(&selector)
.next()
.and_then(|node| node.value().attr("src"))
.map(str::to_string)
}
fn download_fid_from_detail_html(html: &str) -> Option<String> {
let document = Html::parse_document(html);
let selector = Selector::parse("input[name='fid'][value]").ok()?;
document
.select(&selector)
.next()
.and_then(|node| node.value().attr("value"))
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn mixdrop_embed_url_from_download_url(url: &str) -> Option<String> {
let parsed = Url::parse(url).ok()?;
let host = parsed.host_str()?;
let host_lc = host.to_ascii_lowercase();
if !host_lc.contains("mixdrop") && !host_lc.contains("m1xdrop") {
return None;
}
let mut segments = parsed.path_segments()?.filter(|segment| !segment.is_empty());
let kind = segments.next()?.to_ascii_lowercase();
if kind != "e" && kind != "f" {
return None;
}
let media_id = segments.next()?.trim();
if media_id.is_empty() {
return None;
}
Some(format!("{}://{host}/e/{media_id}", parsed.scheme()))
}
fn normalize_possible_protocol_relative(value: &str) -> String {
let trimmed = value.trim();
if trimmed.starts_with("//") {
format!("https:{trimmed}")
} else {
trimmed.to_string()
}
}
fn extract_mixdrop_media_url(html: &str) -> Option<String> {
let direct_regex = Regex::new(r#"MDCore\.wurl\s*=\s*"([^"]+)""#).ok()?;
if let Some(url) = direct_regex
.captures(html)
.and_then(|captures| captures.get(1).map(|value| value.as_str().to_string()))
{
return Some(Self::normalize_possible_protocol_relative(&url));
}
let unpacked = Self::parse_mixin_packed_eval(html)?;
let unpacked_regex = Regex::new(r#"MDCore\.wurl\s*=\s*"([^"]+)""#).ok()?;
unpacked_regex
.captures(&unpacked)
.and_then(|captures| captures.get(1).map(|value| value.as_str().to_string()))
.map(|value| Self::normalize_possible_protocol_relative(&value))
}
fn parse_mixin_packed_eval(html: &str) -> Option<String> {
let eval_regex = Regex::new(
r#"(?s)eval\(function\(p,a,c,k,e,d\)\{.*?\}\('(?P<payload>.*?)',\s*(?P<radix>[0-9]+),\s*(?P<count>[0-9]+),\s*'(?P<tokens>.*?)'\.split\('\|'\)"#,
)
.ok()?;
let captures = eval_regex.captures(html)?;
let payload_raw = captures.name("payload")?.as_str();
let radix = captures.name("radix")?.as_str().parse::<u32>().ok()?;
let count = captures.name("count")?.as_str().parse::<usize>().ok()?;
if !(2..=36).contains(&radix) {
return None;
}
let payload = Self::unescape_js_single_quoted(payload_raw);
let tokens_raw = captures.name("tokens")?.as_str();
let tokens = tokens_raw.split('|').collect::<Vec<_>>();
let mut unpacked = payload;
for index in (0..count).rev() {
let Some(token) = tokens.get(index) else {
continue;
};
if token.is_empty() {
continue;
}
let key = Self::to_radix(index, radix);
let pattern = format!(r"\b{}\b", regex::escape(&key));
let re = Regex::new(&pattern).ok()?;
unpacked = re.replace_all(&unpacked, *token).into_owned();
}
Some(unpacked)
}
fn unescape_js_single_quoted(value: &str) -> String {
let mut output = String::with_capacity(value.len());
let mut chars = value.chars();
while let Some(character) = chars.next() {
if character != '\\' {
output.push(character);
continue;
}
let Some(next) = chars.next() else {
break;
};
match next {
'\\' => output.push('\\'),
'\'' => output.push('\''),
'"' => output.push('"'),
'n' => output.push('\n'),
'r' => output.push('\r'),
't' => output.push('\t'),
_ => output.push(next),
}
}
output
}
fn to_radix(mut value: usize, radix: u32) -> String {
if value == 0 {
return "0".to_string();
}
let alphabet = b"0123456789abcdefghijklmnopqrstuvwxyz";
let mut out = Vec::new();
while value > 0 {
let digit = value % radix as usize;
out.push(alphabet[digit] as char);
value /= radix as usize;
}
out.iter().rev().collect()
}
fn absolute_url(value: &str) -> String {
if value.starts_with("http://") || value.starts_with("https://") {
return value.to_string();
}
if value.starts_with("//") {
return format!("https:{value}");
}
format!("https://archivebate.com/{}", value.trim_start_matches('/'))
}
async fn resolve_mixdrop_media_from_embed(
detail_url: &str,
embed_url: &str,
requester: &mut Requester,
) -> Option<String> {
let response = requester
.get_raw_with_headers_timeout(
embed_url,
Self::html_headers(detail_url),
Some(StdDuration::from_secs(8)),
)
.await
.ok()?;
if !response.status().is_success() {
return None;
}
let html = response.text().await.ok()?;
Self::extract_mixdrop_media_url(&html)
}
}
impl crate::proxies::Proxy for ArchivebateProxy {
async fn get_video_url(&self, url: String, requester: web::types::State<Requester>) -> String {
let Some(detail_url) = Self::normalize_detail_request(&url) else {
return String::new();
};
let mut requester = requester.get_ref().clone();
let detail_html = requester
.get_with_headers(
&detail_url,
Self::html_headers("https://archivebate.com/"),
Some(Version::HTTP_11),
)
.await
.unwrap_or_default();
if detail_html.is_empty() {
return String::new();
}
if let Some(iframe_url) = Self::first_iframe_source_from_html(&detail_html).map(|value| Self::absolute_url(&value)) {
if Self::is_mixdrop_host(&iframe_url) {
if let Some(media_url) =
Self::resolve_mixdrop_media_from_embed(&detail_url, &iframe_url, &mut requester).await
{
return media_url;
}
}
}
if let Some(download_fid) = Self::download_fid_from_detail_html(&detail_html).map(|value| Self::absolute_url(&value)) {
if let Some(embed_url) = Self::mixdrop_embed_url_from_download_url(&download_fid) {
if let Some(media_url) =
Self::resolve_mixdrop_media_from_embed(&detail_url, &embed_url, &mut requester).await
{
return media_url;
}
}
}
String::new()
}
}
#[cfg(test)]
mod tests {
use super::ArchivebateProxy;
#[test]
fn normalizes_detail_request() {
let detail = ArchivebateProxy::normalize_detail_request("archivebate.com/watch/123456");
assert_eq!(detail.as_deref(), Some("https://archivebate.com/watch/123456"));
}
#[test]
fn rejects_non_watch_paths() {
assert!(ArchivebateProxy::normalize_detail_request("archivebate.com/profile/test").is_none());
}
#[test]
fn extracts_mixdrop_wurl_from_packed_eval() {
let html = r#"
<script>
eval(function(p,a,c,k,e,d){e=function(c){return c};if(!''.replace(/^/,String)){while(c--){d[c]=k[c]||c}k=[function(e){return d[e]}];e=function(){return'\\w+'};c=1};while(c--){if(k[c]){p=p.replace(new RegExp('\\b'+e(c)+'\\b','g'),k[c])}}return p}('1.2="//o230m5y6z.3.4/5/6.7?8=9&a=b";',12,12,'|MDCore|wurl|mxcontent|net|v2|r6pkwozjber741|mp4|s|TvNTJe3_z_6nKveumEHk8Q|e|1776460168'.split('|'),0,{}))
</script>
"#;
let extracted = ArchivebateProxy::extract_mixdrop_media_url(html)
.expect("expected extracted media url");
assert_eq!(
extracted,
"https://o230m5y6z.mxcontent.net/v2/r6pkwozjber741.mp4?s=TvNTJe3_z_6nKveumEHk8Q&e=1776460168"
);
}
}

406
src/proxies/doodstream.rs Normal file
View 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
View 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"
);
}
}

171
src/proxies/heavyfetish.rs Normal file
View File

@@ -0,0 +1,171 @@
use std::collections::HashMap;
use ntex::web;
use regex::Regex;
use scraper::{Html, Selector};
use url::Url;
use crate::util::requester::Requester;
#[derive(Debug, Clone)]
pub struct HeavyfetishProxy {}
impl HeavyfetishProxy {
pub fn new() -> Self {
Self {}
}
fn normalize_detail_url(endpoint: &str) -> Option<String> {
let endpoint = endpoint.trim().trim_start_matches('/');
if endpoint.is_empty() {
return None;
}
let detail_url = if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
endpoint.to_string()
} else {
format!("https://{}", endpoint.trim_start_matches('/'))
};
Self::is_allowed_detail_url(&detail_url).then_some(detail_url)
}
fn is_allowed_detail_url(url: &str) -> bool {
let Some(parsed) = Url::parse(url).ok() else {
return false;
};
if parsed.scheme() != "https" {
return false;
}
let Some(host) = parsed.host_str() else {
return false;
};
(host == "heavyfetish.com" || host == "www.heavyfetish.com")
&& parsed.path().starts_with("/videos/")
}
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!("https://heavyfetish.com{value}");
}
if value.starts_with("http://") {
return value.replacen("http://", "https://", 1);
}
value
}
fn quality_from_url(url: &str) -> String {
for quality in ["2160p", "1440p", "1080p", "720p", "480p", "360p", "240p"] {
if url.contains(quality) {
return quality.to_string();
}
}
"480p".to_string()
}
fn quality_score(label: &str) -> u32 {
label
.chars()
.filter(|value| value.is_ascii_digit())
.collect::<String>()
.parse::<u32>()
.unwrap_or(0)
}
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(|captures| captures.get(1))
.map(|value| value.as_str().replace("\\/", "/").replace("\\'", "'"))
}
fn selector(value: &str) -> Option<Selector> {
Selector::parse(value).ok()
}
fn extract_source_url(html: &str) -> Option<String> {
let flashvars_regex = Self::regex(r#"(?s)var\s+flashvars\s*=\s*\{(.*?)\};"#)?;
let value_regex = |key: &str| Self::regex(&format!(r#"{key}:\s*'((?:\\'|[^'])*)'"#));
let mut seen = HashMap::<String, String>::new();
if let Some(flashvars) = flashvars_regex
.captures(html)
.and_then(|value| value.get(1))
.map(|value| value.as_str().to_string())
{
for key in ["video_alt_url2", "video_alt_url", "video_url"] {
let Some(url_regex) = value_regex(key) else {
continue;
};
let Some(text_regex) = value_regex(&format!("{key}_text")) else {
continue;
};
let Some(url) = Self::extract_js_value(&flashvars, &url_regex) else {
continue;
};
let normalized = Self::normalize_url(&url);
if normalized.is_empty() {
continue;
}
let quality = Self::extract_js_value(&flashvars, &text_regex)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| Self::quality_from_url(&normalized));
seen.entry(quality).or_insert(normalized);
}
}
let document = Html::parse_document(html);
let Some(download_selector) = Self::selector("#download_popup a[href*='/get_file/']")
else {
return seen
.iter()
.max_by_key(|(quality, _)| Self::quality_score(quality))
.map(|(_, url)| url.clone());
};
for element in document.select(&download_selector) {
let href = element.value().attr("href").unwrap_or_default();
let normalized = Self::normalize_url(href);
if normalized.is_empty() {
continue;
}
let quality = Self::quality_from_url(&normalized);
seen.entry(quality).or_insert(normalized);
}
seen.iter()
.max_by_key(|(quality, _)| Self::quality_score(quality))
.map(|(_, url)| url.clone())
}
}
impl crate::proxies::Proxy for HeavyfetishProxy {
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 = requester.get(&detail_url, None).await.unwrap_or_default();
if html.is_empty() {
return String::new();
}
Self::extract_source_url(&html).unwrap_or_default()
}
}

341
src/proxies/hqporner.rs Normal file
View File

@@ -0,0 +1,341 @@
use ntex::web;
use regex::Regex;
use std::collections::HashMap;
use url::Url;
use crate::util::requester::Requester;
#[derive(Debug, Clone)]
pub struct HqpornerProxy {}
impl HqpornerProxy {
pub fn new() -> Self {
Self {}
}
fn normalize_detail_request(endpoint: &str) -> Option<(String, Option<u16>)> {
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)) => {
let requested = quality
.trim()
.trim_end_matches('/')
.trim_end_matches('p')
.parse::<u16>()
.ok();
(detail, requested)
}
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))
}
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 == "hqporner.com" || host == "www.hqporner.com") && url.path().starts_with("/hdporn/")
}
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!("https://www.hqporner.com{value}");
}
if value.starts_with("http://") {
return value.replacen("http://", "https://", 1);
}
value.to_string()
}
fn regex(value: &str) -> Option<Regex> {
Regex::new(value).ok()
}
fn extract_player_url(detail_html: &str) -> Option<String> {
let pattern = r#"(?is)url\s*:\s*['"](/blocks/(?:altplayer|nativeplayer)\.php\?i=[^'"]+)['"]"#;
let captures = Self::regex(pattern)?.captures(detail_html)?;
let path = captures.get(1)?.as_str();
Some(Self::normalize_url(path))
}
fn extract_source_url(player_html: &str) -> Option<String> {
for source in player_html.split("<source ").skip(1) {
let src = source
.split("src=\\\"")
.nth(1)
.and_then(|s| s.split("\\\"").next())
.or_else(|| {
source
.split("src=\"")
.nth(1)
.and_then(|s| s.split('"').next())
})
.unwrap_or_default();
let url = Self::normalize_url(src);
if !url.is_empty() {
return Some(url);
}
}
let iframe_regexes = [
r#"(?is)<iframe[^>]+src="([^"]+)""#,
r#"(?is)<iframe[^>]+src='([^']+)'"#,
r#"(?is)src=\\\"([^\\"]+)\\\""#,
r#"(?is)src=\\'([^\\']+)\\'"#,
];
for pattern in iframe_regexes {
let Some(regex) = Self::regex(pattern) else {
continue;
};
if let Some(url) = regex
.captures(player_html)
.and_then(|caps| caps.get(1))
.map(|m| Self::normalize_url(m.as_str()))
.filter(|value| !value.is_empty())
{
return Some(url);
}
}
let source_regex = Self::regex(r#"src=\\\"([^\\"]+)\\\""#)?;
source_regex
.captures(player_html)
.and_then(|caps| caps.get(1))
.map(|m| Self::normalize_url(m.as_str()))
.filter(|value| !value.is_empty())
}
fn extract_quality_urls(video_page_html: &str) -> HashMap<u16, String> {
let mut urls = HashMap::new();
let Some(regex) =
Self::regex(r#"(?i)(?:https?:)?//[^"'\\\s]+/pubs/[A-Za-z0-9._-]+/(360|720|1080)\.mp4"#)
else {
return urls;
};
for captures in regex.captures_iter(video_page_html) {
let Some(full_match) = captures.get(0) else {
continue;
};
let Some(quality_match) = captures.get(1) else {
continue;
};
let Some(quality) = quality_match.as_str().parse::<u16>().ok() else {
continue;
};
let normalized = Self::normalize_url(full_match.as_str());
if !normalized.is_empty() {
urls.insert(quality, normalized);
}
}
urls
}
fn select_quality_url(quality_urls: &HashMap<u16, String>, requested: Option<u16>) -> Option<String> {
let fallbacks = match requested.unwrap_or(1080) {
1080 => [1080u16, 720, 360].as_slice(),
720 => [720u16, 360].as_slice(),
360 => [360u16].as_slice(),
other if other > 1080 => [1080u16, 720, 360].as_slice(),
other if other > 720 => [720u16, 360].as_slice(),
_ => [360u16].as_slice(),
};
for quality in fallbacks {
if let Some(url) = quality_urls.get(quality) {
return Some(url.clone());
}
}
if let Some(url) = quality_urls.get(&1080) {
return Some(url.clone());
}
if let Some(url) = quality_urls.get(&720) {
return Some(url.clone());
}
quality_urls.get(&360).cloned()
}
}
impl crate::proxies::Proxy for HqpornerProxy {
async fn get_video_url(&self, url: String, requester: web::types::State<Requester>) -> String {
let Some((detail_url, requested_quality)) = Self::normalize_detail_request(&url) else {
return String::new();
};
let mut requester = requester.get_ref().clone();
let headers = vec![("Referer".to_string(), "https://hqporner.com/".to_string())];
let detail_html = requester
.get_with_headers(&detail_url, headers.clone(), None)
.await
.unwrap_or_default();
if detail_html.is_empty() {
return String::new();
}
let mut source_page_url = String::new();
if let Some(player_url) = Self::extract_player_url(&detail_html) {
let player_html = requester
.get_with_headers(&player_url, headers.clone(), None)
.await
.unwrap_or_default();
if !player_html.is_empty() {
if let Some(url) = Self::extract_source_url(&player_html) {
source_page_url = url;
}
}
}
if source_page_url.is_empty() {
source_page_url = Self::extract_source_url(&detail_html).unwrap_or_default();
}
if source_page_url.is_empty() {
return String::new();
}
let source_page_html = requester
.get_with_headers(&source_page_url, headers, None)
.await
.unwrap_or_default();
if source_page_html.is_empty() {
return String::new();
}
let quality_urls = Self::extract_quality_urls(&source_page_html);
if quality_urls.is_empty() {
return String::new();
}
Self::select_quality_url(&quality_urls, requested_quality).unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::HqpornerProxy;
use std::collections::HashMap;
#[test]
fn extract_source_url_supports_iframe_src() {
let html = r#"<iframe width="560" height="350" src="//mydaddy.cc/video/f7cbb41e218d3b1dca/&alt" frameborder="0" allowfullscreen=""></iframe>"#;
let extracted = HqpornerProxy::extract_source_url(html);
assert_eq!(
extracted.as_deref(),
Some("https://mydaddy.cc/video/f7cbb41e218d3b1dca/&alt")
);
}
#[test]
fn extract_source_url_supports_source_tag_src() {
let html =
r#"<video><source src=\"https://cdn.example.com/video.mp4\" type=\"video/mp4\"></video>"#;
let extracted = HqpornerProxy::extract_source_url(html);
assert_eq!(
extracted.as_deref(),
Some("https://cdn.example.com/video.mp4")
);
}
#[test]
fn extract_player_url_supports_altplayer_path() {
let html = r#"
<script>
function altPlayer() {
$.ajax({
type: 'POST',
url: '/blocks/altplayer.php?i=//mydaddy.cc/video/f7cbb41e218d3b1dca/',
success: function(data) {}
});
}
</script>
"#;
let extracted = HqpornerProxy::extract_player_url(html);
assert_eq!(
extracted.as_deref(),
Some(
"https://www.hqporner.com/blocks/altplayer.php?i=//mydaddy.cc/video/f7cbb41e218d3b1dca/"
)
);
}
#[test]
fn extract_quality_urls_from_mydaddy_html() {
let html = r#"
timelinePreview:{file:"//s43.bigcdn.cc/pubs/69ecfb39b17117.73515587/tile.vtt",spriteRelativePath:true,type:"VTT"}
<source src="//s43.bigcdn.cc/pubs/69ecfb39b17117.73515587/360.mp4" title="360p" type="video/mp4" />
<source src="//s43.bigcdn.cc/pubs/69ecfb39b17117.73515587/720.mp4" title="720p HD" type="video/mp4" />
<source src="//s43.bigcdn.cc/pubs/69ecfb39b17117.73515587/1080.mp4" title="1080p Full HD" type="video/mp4" />
"#;
let urls = HqpornerProxy::extract_quality_urls(html);
assert_eq!(
urls.get(&360).map(String::as_str),
Some("https://s43.bigcdn.cc/pubs/69ecfb39b17117.73515587/360.mp4")
);
assert_eq!(
urls.get(&720).map(String::as_str),
Some("https://s43.bigcdn.cc/pubs/69ecfb39b17117.73515587/720.mp4")
);
assert_eq!(
urls.get(&1080).map(String::as_str),
Some("https://s43.bigcdn.cc/pubs/69ecfb39b17117.73515587/1080.mp4")
);
}
#[test]
fn select_quality_url_falls_back_to_next_lower_quality() {
let mut urls = HashMap::new();
urls.insert(
360,
"https://s43.bigcdn.cc/pubs/69ecfb39b17117.73515587/360.mp4".to_string(),
);
urls.insert(
720,
"https://s43.bigcdn.cc/pubs/69ecfb39b17117.73515587/720.mp4".to_string(),
);
let requested_1080 = HqpornerProxy::select_quality_url(&urls, Some(1080));
assert_eq!(
requested_1080.as_deref(),
Some("https://s43.bigcdn.cc/pubs/69ecfb39b17117.73515587/720.mp4")
);
let requested_720 = HqpornerProxy::select_quality_url(&urls, Some(720));
assert_eq!(
requested_720.as_deref(),
Some("https://s43.bigcdn.cc/pubs/69ecfb39b17117.73515587/720.mp4")
);
let requested_360 = HqpornerProxy::select_quality_url(&urls, Some(360));
assert_eq!(
requested_360.as_deref(),
Some("https://s43.bigcdn.cc/pubs/69ecfb39b17117.73515587/360.mp4")
);
}
}

View 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()))
}

118
src/proxies/javtiful.rs Normal file
View File

@@ -0,0 +1,118 @@
use ntex::web;
use url::Url;
use wreq::Version;
use crate::util::requester::Requester;
#[derive(Debug, Clone)]
pub struct JavtifulProxy {}
impl JavtifulProxy {
pub fn new() -> Self {
JavtifulProxy {}
}
fn normalize_detail_request(endpoint: &str) -> Option<(String, String)> {
let endpoint = endpoint.trim().trim_start_matches('/');
if endpoint.is_empty() {
return None;
}
let detail_url = if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
endpoint.to_string()
} else if endpoint.starts_with("javtiful.com/") || endpoint.starts_with("www.javtiful.com/")
{
format!("https://{endpoint}")
} else {
format!("https://javtiful.com/{endpoint}")
};
let detail_url = if detail_url.starts_with("http://") {
detail_url.replacen("http://", "https://", 1)
} else {
detail_url
};
if !Self::is_allowed_detail_url(&detail_url) {
return None;
}
let video_id = Url::parse(&detail_url)
.ok()
.and_then(|url| {
let mut segments = url.path_segments()?;
if segments.next()? != "video" {
return None;
}
segments.next().map(ToOwned::to_owned)
})
.filter(|value| value.chars().all(|c| c.is_ascii_digit()) && !value.is_empty())?;
Some((detail_url, video_id))
}
fn is_allowed_detail_url(url: &str) -> bool {
let Some(parsed) = Url::parse(url).ok() else {
return false;
};
if parsed.scheme() != "https" {
return false;
}
let Some(host) = parsed.host_str() else {
return false;
};
(host == "javtiful.com" || host == "www.javtiful.com")
&& parsed.path().starts_with("/video/")
}
pub async fn get_video_url(
&self,
url: String,
requester: web::types::State<Requester>,
) -> String {
let mut requester = requester.get_ref().clone();
let Some((detail_url, _)) = Self::normalize_detail_request(&url) else {
println!("JavtifulProxy: Invalid detail URL: {url}");
return String::new();
};
let html = requester.get(&detail_url, Some(Version::HTTP_11)).await;
let Ok(html) = html else {
return String::new();
};
if html.is_empty() {
return String::new();
}
let mut media_url: String = html.split("playerSources\":[{\"src\":\"")
.nth(1)
.and_then(|s| s.split('"').next())
.map(str::trim)
.map(ToOwned::to_owned).unwrap_or_default().replace("\\u0026", "&");
media_url = match media_url.starts_with("/"){
true => format!("https://javtiful.com{media_url}"),
false => media_url
};
return media_url;
}
}
#[cfg(test)]
mod tests {
use super::JavtifulProxy;
#[test]
fn normalizes_detail_request_with_full_url() {
let (url, video_id) =
JavtifulProxy::normalize_detail_request("https://javtiful.com/video/106796/fns-176")
.expect("detail request should parse");
assert_eq!(url, "https://javtiful.com/video/106796/fns-176");
assert_eq!(video_id, "106796");
}
#[test]
fn normalizes_detail_request_with_path_only() {
let (url, video_id) = JavtifulProxy::normalize_detail_request("video/1000/demo")
.expect("detail request should parse");
assert_eq!(url, "https://javtiful.com/video/1000/demo");
assert_eq!(video_id, "1000");
}
}

70
src/proxies/mod.rs Normal file
View File

@@ -0,0 +1,70 @@
use crate::proxies::archivebate::ArchivebateProxy;
use crate::proxies::doodstream::DoodstreamProxy;
use crate::proxies::heavyfetish::HeavyfetishProxy;
use crate::proxies::hqporner::HqpornerProxy;
use crate::proxies::pornhd3x::Pornhd3xProxy;
use ntex::web;
use crate::proxies::pimpbunny::PimpbunnyProxy;
use crate::proxies::porndish::PorndishProxy;
use crate::proxies::shooshtime::ShooshtimeProxy;
use crate::proxies::spankbang::SpankbangProxy;
use crate::proxies::vjav::VjavProxy;
use crate::{proxies::sxyprn::SxyprnProxy, util::requester::Requester};
pub mod archivebate;
pub mod doodstream;
pub mod hanimecdn;
pub mod heavyfetish;
pub mod hqporner;
pub mod hqpornerthumb;
pub mod javtiful;
pub mod noodlemagazine;
pub mod pimpbunny;
pub mod porndish;
pub mod porndishthumb;
pub mod pornhd3x;
pub mod pornhubthumb;
pub mod shooshtime;
pub mod spankbang;
pub mod sxyprn;
pub mod vjav;
#[derive(Debug, Clone)]
pub enum AnyProxy {
Archivebate(ArchivebateProxy),
Doodstream(DoodstreamProxy),
Sxyprn(SxyprnProxy),
Javtiful(javtiful::JavtifulProxy),
Pornhd3x(Pornhd3xProxy),
Pimpbunny(PimpbunnyProxy),
Porndish(PorndishProxy),
Spankbang(SpankbangProxy),
Shooshtime(ShooshtimeProxy),
Hqporner(HqpornerProxy),
Heavyfetish(HeavyfetishProxy),
Vjav(VjavProxy),
}
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::Archivebate(p) => p.get_video_url(url, requester).await,
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,
AnyProxy::Shooshtime(p) => p.get_video_url(url, requester).await,
AnyProxy::Hqporner(p) => p.get_video_url(url, requester).await,
AnyProxy::Heavyfetish(p) => p.get_video_url(url, requester).await,
AnyProxy::Vjav(p) => p.get_video_url(url, requester).await,
}
}
}

View 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
View 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, &detail_url);
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")
);
}
}

Some files were not shown because too many files have changed in this diff Show More