Compare commits

...

70 Commits

Author SHA1 Message Date
27ce8b0ceb 0.47.12 2026-03-13 04:52:24 +01:00
0e5724533f Removed Manticore tools to prevent confusion. 2026-03-13 04:52:22 +01:00
cea58d12ff 0.47.11 2026-03-13 04:45:10 +01:00
25034e7a4b Added manticore table recreation to stash sync. 2026-03-13 04:45:07 +01:00
cd4a7ce9c8 0.47.10 2026-03-10 22:12:41 +01:00
2229255ff4 Disabled actor tags for performance evaluation. 2026-03-10 22:12:39 +01:00
299dbe3239 0.47.9 2026-03-07 02:30:53 +01:00
deced84c59 Added function for scene facet composition. 2026-03-07 02:30:51 +01:00
0150ae8d1c 0.47.8 2026-03-07 02:09:06 +01:00
6877ee75ed Added toggle to select actor tags or all tags in filters. 2026-03-07 02:09:04 +01:00
b5726aec84 0.47.7 2026-03-06 06:12:25 +01:00
6c1f1c2a1c Added cache reload button to admin panel so restarts are needed less often. 2026-03-06 06:12:23 +01:00
9a59448933 0.47.6 2026-03-06 05:13:23 +01:00
c55f6d2cf2 Added /tour filter to affiliate link composition. Added note to scene tag edit field. 2026-03-06 05:13:21 +01:00
16bf7b019f 0.47.5 2026-03-06 03:57:56 +01:00
1ff5b6b036 Fixed global tags not showing up if no actor is filtered for. 2026-03-06 03:57:53 +01:00
3c47a1b14e 0.47.4 2026-03-05 17:23:31 +01:00
da6ccccab4 Fixed tile tag deduping order. 2026-03-05 17:23:29 +01:00
f79ef53ebd 0.47.3 2026-03-05 16:34:43 +01:00
185984bf0c Fixed actor and channel aggregation duplicates. 2026-03-05 16:34:41 +01:00
dd522c1fb1 0.47.2 2026-03-05 02:00:12 +01:00
07f290ad85 Excluding actor-specific tags from aggregated tag filter. 2026-03-05 02:00:10 +01:00
eefc213144 0.47.1 2026-03-04 04:03:51 +01:00
849b4f0de7 Improved scene actor tag spacing CSS. 2026-03-04 04:03:48 +01:00
bf3a712de8 0.47.0 2026-03-04 03:57:07 +01:00
46839b48cf Added tag actor editing. 2026-03-04 03:57:04 +01:00
f4447b23de Flipped scene and actor tags. 2026-03-04 02:53:49 +01:00
8a734b9fa9 0.46.24 2026-03-04 02:52:57 +01:00
34ca806e84 Displaying actor-specific scene tags. 2026-03-04 02:52:55 +01:00
b7ac8917e9 0.46.23 2026-03-02 22:35:43 +01:00
39b25209f4 Selecting independent site banners from network directory. 2026-03-02 22:35:41 +01:00
ce287bc006 0.46.22 2026-03-02 04:08:50 +01:00
f0e3e741ff Fixed banner path for independent sites. 2026-03-02 04:08:47 +01:00
856928760d 0.46.21 2026-03-01 17:51:55 +01:00
a4bd5d0d83 Restricting trailers to logged in users (there seems to have been a regression). 2026-03-01 17:51:49 +01:00
09b6db6774 0.46.20 2026-03-01 05:14:34 +01:00
be061b956e Skipping joins for aggregated actor fetch. 2026-03-01 05:14:32 +01:00
074b5a4ae4 0.46.19 2026-03-01 05:08:54 +01:00
e1e83994e0 Don't fetch actor assets for scene and movie aggregations. 2026-03-01 05:08:51 +01:00
9cc4b21b76 0.46.18 2026-03-01 04:22:35 +01:00
6634ecaa48 Optionally chaining user abilities on entity page, though abilities should always be curated. 2026-03-01 04:22:32 +01:00
b31f74ed29 0.46.17 2026-02-22 06:07:31 +01:00
30c5fc4c88 Specifying ID table in scene tags query. 2026-02-22 06:07:28 +01:00
a45fd152df 0.46.16 2026-02-12 21:07:57 +01:00
60f01d859e Restored compilation global filter position. 2026-02-12 21:07:55 +01:00
6d4e033fb7 0.46.15 2026-02-12 21:07:16 +01:00
bf635df863 Added extreme insertion global filter. 2026-02-12 21:07:14 +01:00
1732b4cf4d 0.46.14 2026-02-12 01:28:36 +01:00
a2e268913a Added Matrix link to footer. 2026-02-12 01:28:34 +01:00
dfb04e5e01 0.46.13 2026-02-08 21:41:22 +01:00
c0ce844169 Fixed scene tile banner styling disrupting other banner positions. 2026-02-08 21:41:20 +01:00
56acc42f17 0.46.12 2026-02-08 05:19:01 +01:00
7cee6639e7 Showing chapter posters before scene photos in scene album. 2026-02-08 05:18:58 +01:00
62753a4af0 0.46.11 2026-02-08 02:02:16 +01:00
2d4d2b1105 Fixed scene chapter photos not curated. 2026-02-08 02:02:14 +01:00
09ed130327 0.46.10 2026-02-07 17:47:11 +01:00
1414a846ec Added edit tooltips. 2026-02-07 17:47:10 +01:00
19dc029e28 Improved chapter date display. 2026-02-07 05:54:17 +01:00
67176db933 0.46.9 2026-02-07 05:10:06 +01:00
95e8982696 Showing heading and dates on chapters. 2026-02-07 05:10:02 +01:00
b8a03cd6fb 0.46.8 2026-02-07 02:03:17 +01:00
aad4ff8079 Centered banner blur background. 2026-02-07 02:03:15 +01:00
8821b3a00d 0.46.7 2026-02-07 01:11:55 +01:00
db43102487 Added blurred backdrop to scene tile banners to compensate for lack of upscaling. 2026-02-07 01:11:53 +01:00
a3072a4967 0.46.6 2026-02-07 00:56:05 +01:00
983e24835f Removed auto width from tile banners to prevent blurry upscaling. Fixed tag photo paths. 2026-02-07 00:56:02 +01:00
1c982124b0 0.46.5 2026-02-06 23:55:08 +01:00
6aaa3ad30c Allowing campaigns to be marked as non-global. 2026-02-06 23:55:06 +01:00
57dfa621df 0.46.4 2026-02-06 23:05:54 +01:00
e98254d444 Fixed tag photo paths. Fixed affiliate prefix slash logic. 2026-02-06 23:05:52 +01:00
45 changed files with 656 additions and 490 deletions

View File

@@ -62,6 +62,7 @@
--text: #222; --text: #222;
--text-light: #fff; --text-light: #fff;
--text-piss: #b92;
/* --link: #48f; */ /* --link: #48f; */
--link: var(--primary); --link: var(--primary);
@@ -101,6 +102,7 @@
--background-dim: var(--shadow-weak-10); --background-dim: var(--shadow-weak-10);
--text: #fcfcfc; --text: #fcfcfc;
--text-piss: #ba0;
--glass-weak-50: rgba(255, 255, 255, .02); --glass-weak-50: rgba(255, 255, 255, .02);
--glass-weak-40: rgba(255, 255, 255, .05); --glass-weak-40: rgba(255, 255, 255, .05);

View File

@@ -0,0 +1,6 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path d="M14.422 7.078c-0.786-0.672-1.807-1.078-2.922-1.078-2.202 0-4.035 1.582-4.424 3.672h1.54c0.36-1.253 1.517-2.172 2.884-2.172 0.7 0 1.344 0.241 1.855 0.645l-1.855 1.855h4.5v-4.5l-1.578 1.578z"></path>
<path d="M11.5 13.5c-0.7 0-1.344-0.241-1.855-0.645l1.855-1.855h-4.5v4.5l1.578-1.578c0.786 0.672 1.807 1.078 2.922 1.078 2.202 0 4.035-1.582 4.424-3.672h-1.54c-0.36 1.253-1.517 2.172-2.884 2.172z"></path>
<path d="M2.158 9.146c-0.762-0.275-1.074-0.562-1.158-0.677v-2.531c0.995 0.643 2.64 1.062 4.5 1.062 0.169 0 0.335-0.004 0.5-0.010v-1.314c-0.165 0.008-0.332 0.012-0.5 0.012-1.152 0-2.252-0.181-3.098-0.51-0.707-0.275-0.996-0.562-1.074-0.677 0.078-0.115 0.367-0.402 1.074-0.677 0.846-0.329 1.946-0.51 3.098-0.51s2.252 0.181 3.098 0.51c0.707 0.275 0.995 0.562 1.074 0.677-0.061 0.090-0.252 0.287-0.674 0.5h1.892c0.072-0.162 0.11-0.329 0.11-0.5 0-1.381-2.462-2.5-5.5-2.5s-5.5 1.119-5.5 2.5v8c0 1.381 2.462 2.5 5.5 2.5 0.169 0 0.335-0.004 0.5-0.010v-1.312c-0.165 0.007-0.332 0.010-0.5 0.010-1.242 0-2.429-0.181-3.342-0.51-0.763-0.275-1.074-0.562-1.158-0.677v-2.594c0.995 0.643 2.64 1.062 4.5 1.062 0.169 0 0.335-0.004 0.5-0.010v-1.312c-0.165 0.007-0.332 0.010-0.5 0.010-1.242 0-2.429-0.181-3.342-0.51z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path d="M5.5 7c3.038 0 5.5-1.119 5.5-2.5s-2.462-2.5-5.5-2.5-5.5 1.119-5.5 2.5v8c0 1.381 2.462 2.5 5.5 2.5 0.503 0 0.99-0.031 1.452-0.088-0.287-0.381-0.526-0.8-0.711-1.246-0.244 0.014-0.491 0.022-0.741 0.022-1.242 0-2.429-0.181-3.342-0.51-0.763-0.275-1.074-0.562-1.158-0.677v-2.594c0.995 0.643 2.64 1.062 4.5 1.062 0.114 0 0.226-0.002 0.338-0.005 0.043-0.459 0.141-0.902 0.287-1.324-0.206 0.010-0.415 0.016-0.625 0.016-1.242 0-2.429-0.181-3.342-0.51-0.762-0.275-1.074-0.562-1.158-0.677v-2.531c0.995 0.643 2.64 1.062 4.5 1.062zM2.402 3.823c0.846-0.329 1.946-0.51 3.098-0.51s2.252 0.181 3.098 0.51c0.707 0.275 0.995 0.562 1.074 0.677-0.078 0.115-0.367 0.402-1.074 0.677-0.846 0.329-1.946 0.51-3.098 0.51s-2.252-0.181-3.098-0.51c-0.707-0.275-0.996-0.562-1.074-0.677 0.078-0.115 0.367-0.402 1.074-0.677z"></path>
<path d="M11.5 7c-2.485 0-4.5 2.015-4.5 4.5s2.015 4.5 4.5 4.5 4.5-2.015 4.5-4.5-2.015-4.5-4.5-4.5zM14 12h-3v-3h1v2h2v1z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,6 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path d="M11.5 7c-2.485 0-4.5 2.015-4.5 4.5s2.015 4.5 4.5 4.5c2.485 0 4.5-2.015 4.5-4.5s-2.015-4.5-4.5-4.5zM11.5 14.688c-1.758 0-3.188-1.43-3.188-3.188s1.43-3.188 3.188-3.188 3.188 1.43 3.188 3.188-1.43 3.188-3.188 3.188z"></path>
<path d="M12 11v-2h-1v3h3v-1z"></path>
<path d="M5.5 7c3.038 0 5.5-1.119 5.5-2.5s-2.462-2.5-5.5-2.5-5.5 1.119-5.5 2.5v8c0 1.381 2.462 2.5 5.5 2.5 0.503 0 0.99-0.031 1.452-0.088-0.287-0.381-0.526-0.8-0.711-1.246-0.244 0.014-0.491 0.022-0.741 0.022-1.242 0-2.429-0.181-3.342-0.51-0.763-0.275-1.074-0.562-1.158-0.677v-2.594c0.995 0.643 2.64 1.062 4.5 1.062 0.114 0 0.226-0.002 0.338-0.005 0.043-0.459 0.141-0.902 0.287-1.324-0.206 0.010-0.415 0.016-0.625 0.016-1.242 0-2.429-0.181-3.342-0.51-0.762-0.275-1.074-0.562-1.158-0.677v-2.531c0.995 0.643 2.64 1.062 4.5 1.062zM2.402 3.823c0.846-0.329 1.946-0.51 3.098-0.51s2.252 0.181 3.098 0.51c0.707 0.275 0.995 0.562 1.074 0.677-0.078 0.115-0.367 0.402-1.074 0.677-0.846 0.329-1.946 0.51-3.098 0.51s-2.252-0.181-3.098-0.51c-0.707-0.275-0.996-0.562-1.074-0.677 0.078-0.115 0.367-0.402 1.074-0.677z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

5
assets/img/icons/loop3.svg Executable file
View File

@@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path d="M13.901 2.599c-1.463-1.597-3.565-2.599-5.901-2.599-4.418 0-8 3.582-8 8h1.5c0-3.59 2.91-6.5 6.5-6.5 1.922 0 3.649 0.835 4.839 2.161l-2.339 2.339h5.5v-5.5l-2.099 2.099z"></path>
<path d="M14.5 8c0 3.59-2.91 6.5-6.5 6.5-1.922 0-3.649-0.835-4.839-2.161l2.339-2.339h-5.5v5.5l2.099-2.099c1.463 1.597 3.565 2.599 5.901 2.599 4.418 0 8-3.582 8-8h-1.5z"></path>
</svg>

After

Width:  |  Height:  |  Size: 500 B

View File

@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.1"
viewBox="0 0 75 32"
id="svg1"
sodipodi:docname="matrix-full.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs1" />
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="12.000629"
inkscape:cx="27.79021"
inkscape:cy="22.165505"
inkscape:window-width="1920"
inkscape:window-height="1020"
inkscape:window-x="0"
inkscape:window-y="32"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" />
<title
id="title1">Matrix (protocol) logo</title>
<g
id="g1">
<path
d="m0.936 0.732v30.52h2.194v0.732h-3.035v-31.98h3.034v0.732zm8.45 9.675v1.544h0.044a4.461 4.461 0 0 1 1.487-1.368c0.58-0.323 1.245-0.485 1.993-0.485 0.72 0 1.377 0.14 1.972 0.42 0.595 0.279 1.047 0.771 1.355 1.477 0.338-0.5 0.796-0.941 1.377-1.323 0.58-0.383 1.266-0.574 2.06-0.574 0.602 0 1.16 0.074 1.674 0.22 0.514 0.148 0.954 0.383 1.322 0.707 0.366 0.323 0.653 0.746 0.859 1.268 0.205 0.522 0.308 1.15 0.308 1.887v7.633h-3.127v-6.464c0-0.383-0.015-0.743-0.044-1.082a2.305 2.305 0 0 0-0.242-0.882 1.473 1.473 0 0 0-0.584-0.596c-0.257-0.146-0.606-0.22-1.047-0.22-0.44 0-0.796 0.085-1.068 0.253-0.272 0.17-0.485 0.39-0.639 0.662a2.654 2.654 0 0 0-0.308 0.927 7.074 7.074 0 0 0-0.078 1.048v6.354h-3.128v-6.398c0-0.338-7e-3 -0.673-0.021-1.004a2.825 2.825 0 0 0-0.188-0.916 1.411 1.411 0 0 0-0.55-0.673c-0.258-0.168-0.636-0.253-1.135-0.253a2.33 2.33 0 0 0-0.584 0.1 1.94 1.94 0 0 0-0.705 0.374c-0.228 0.184-0.422 0.449-0.584 0.794-0.161 0.346-0.242 0.798-0.242 1.357v6.619h-3.129v-11.41zm16.46 1.677a3.751 3.751 0 0 1 1.233-1.17 5.37 5.37 0 0 1 1.685-0.629 9.579 9.579 0 0 1 1.884-0.187c0.573 0 1.153 0.04 1.74 0.121 0.588 0.081 1.124 0.24 1.609 0.475 0.484 0.235 0.88 0.562 1.19 0.981 0.308 0.42 0.462 0.975 0.462 1.666v5.934c0 0.516 0.03 1.008 0.088 1.478 0.058 0.471 0.161 0.824 0.308 1.06h-3.171a4.435 4.435 0 0 1-0.22-1.104c-0.5 0.515-1.087 0.876-1.762 1.081a7.084 7.084 0 0 1-2.071 0.31c-0.544 0-1.05-0.067-1.52-0.2a3.472 3.472 0 0 1-1.234-0.617 2.87 2.87 0 0 1-0.826-1.059c-0.199-0.426-0.298-0.934-0.298-1.522 0-0.647 0.114-1.18 0.342-1.6 0.227-0.419 0.52-0.753 0.881-1.004 0.36-0.25 0.771-0.437 1.234-0.562 0.462-0.125 0.929-0.224 1.399-0.298 0.47-0.073 0.932-0.132 1.387-0.176 0.456-0.044 0.86-0.11 1.212-0.199 0.353-0.088 0.631-0.217 0.837-0.386s0.301-0.415 0.287-0.74c0-0.337-0.055-0.606-0.166-0.804a1.217 1.217 0 0 0-0.44-0.464 1.737 1.737 0 0 0-0.639-0.22 5.292 5.292 0 0 0-0.782-0.055c-0.617 0-1.101 0.132-1.454 0.397-0.352 0.264-0.558 0.706-0.617 1.323h-3.128c0.044-0.735 0.227-1.345 0.55-1.83zm6.179 4.423a5.095 5.095 0 0 1-0.639 0.165 9.68 9.68 0 0 1-0.716 0.11c-0.25 0.03-0.5 0.067-0.749 0.11a5.616 5.616 0 0 0-0.694 0.177 2.057 2.057 0 0 0-0.594 0.298c-0.17 0.125-0.305 0.284-0.408 0.474-0.103 0.192-0.154 0.434-0.154 0.728 0 0.28 0.051 0.515 0.154 0.706 0.103 0.192 0.242 0.342 0.419 0.453 0.176 0.11 0.381 0.187 0.617 0.231 0.234 0.044 0.477 0.066 0.726 0.066 0.617 0 1.094-0.102 1.432-0.309 0.338-0.205 0.587-0.452 0.75-0.739 0.16-0.286 0.26-0.576 0.297-0.87 0.036-0.295 0.055-0.53 0.055-0.707v-1.17a1.4 1.4 0 0 1-0.496 0.277zm11.86-6.1v2.096h-2.291v5.647c0 0.53 0.088 0.883 0.264 1.059 0.176 0.177 0.529 0.265 1.057 0.265 0.177 0 0.345-7e-3 0.507-0.022 0.161-0.015 0.316-0.037 0.463-0.066v2.426a7.49 7.49 0 0 1-0.882 0.089 21.67 21.67 0 0 1-0.947 0.022c-0.484 0-0.944-0.034-1.377-0.1a3.233 3.233 0 0 1-1.145-0.386 2.04 2.04 0 0 1-0.782-0.816c-0.191-0.353-0.287-0.816-0.287-1.39v-6.728h-1.894v-2.096h1.894v-3.42h3.129v3.42h2.29zm4.471 0v2.118h0.044a3.907 3.907 0 0 1 1.454-1.754 4.213 4.213 0 0 1 1.036-0.497 3.734 3.734 0 0 1 1.145-0.176c0.206 0 0.433 0.037 0.683 0.11v2.912a5.862 5.862 0 0 0-0.528-0.077 5.566 5.566 0 0 0-0.595-0.033c-0.573 0-1.058 0.096-1.454 0.287a2.52 2.52 0 0 0-0.958 0.783 3.143 3.143 0 0 0-0.518 1.158 6.32 6.32 0 0 0-0.154 1.434v5.14h-3.128v-11.4zm5.684-1.765v-2.582h3.128v2.582h-3.127zm3.128 1.765v11.4h-3.127v-11.4h3.128zm1.63 0h3.569l2.005 2.978 1.982-2.978h3.459l-3.745 5.339 4.208 6.067h-3.57l-2.378-3.596-2.38 3.596h-3.502l4.097-6.001zm15.3 20.84v-30.52h-2.194v-0.732h3.035v31.98h-3.035v-0.732z"
id="path1" />
</g>
<metadata
id="metadata1">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:title>Matrix (protocol) logo</dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg
fill="#000000"
width="800px"
height="800px"
viewBox="0 0 32 32"
version="1.1"
id="svg1"
sodipodi:docname="matrix.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="0.23142407"
inkscape:cx="412.66234"
inkscape:cy="557.41825"
inkscape:window-width="1920"
inkscape:window-height="1020"
inkscape:window-x="0"
inkscape:window-y="32"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" />
<path
d="M0.844 0.735v30.531h2.197v0.735h-3.041v-32h3.041v0.735zM10.235 10.412v1.547h0.041c0.412-0.595 0.912-1.047 1.489-1.371 0.579-0.323 1.251-0.484 2-0.484 0.719 0 1.38 0.141 1.975 0.417 0.599 0.281 1.047 0.776 1.359 1.479 0.339-0.5 0.803-0.943 1.38-1.323 0.579-0.38 1.267-0.573 2.063-0.573 0.604 0 1.161 0.073 1.677 0.224 0.521 0.145 0.959 0.38 1.328 0.703 0.365 0.329 0.651 0.751 0.86 1.272 0.203 0.52 0.307 1.151 0.307 1.891v7.635h-3.129v-6.468c0-0.381-0.016-0.745-0.048-1.084-0.020-0.307-0.099-0.604-0.239-0.88-0.131-0.251-0.333-0.459-0.584-0.593-0.255-0.152-0.609-0.224-1.047-0.224-0.443 0-0.797 0.083-1.068 0.249-0.265 0.167-0.489 0.396-0.64 0.667-0.161 0.287-0.265 0.604-0.308 0.927-0.052 0.349-0.077 0.699-0.083 1.048v6.359h-3.131v-6.401c0-0.339-0.005-0.672-0.025-1-0.011-0.317-0.073-0.624-0.193-0.916-0.104-0.281-0.301-0.516-0.552-0.672-0.255-0.167-0.636-0.255-1.136-0.255-0.151 0-0.348 0.031-0.588 0.099-0.24 0.067-0.479 0.192-0.703 0.375-0.229 0.188-0.428 0.453-0.589 0.797-0.161 0.343-0.239 0.796-0.239 1.359v6.62h-3.131v-11.421zM31.156 31.265v-30.531h-2.197v-0.735h3.041v32h-3.041v-0.735z"
id="path1"
style="fill-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

4
assets/img/icons/reset.svg Executable file
View File

@@ -0,0 +1,4 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path d="M4.126 9c-0.082 0.32-0.126 0.655-0.126 1 0 2.209 1.791 4 4 4s4-1.791 4-4-1.791-4-4-4v3l-5-4 5-4v3c3.314 0 6 2.686 6 6s-2.686 6-6 6-6-2.686-6-6c0-0.341 0.029-0.675 0.084-1h2.043z"></path>
</svg>

After

Width:  |  Height:  |  Size: 334 B

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generated by IcoMoon.io -->
<svg
version="1.1"
width="16"
height="16"
viewBox="0 0 16 16"
id="svg2"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs2" />
<path
id="path2"
d="M 7 0 C 4 0 4 2.015 4 4.5 C 4 6.048 4.898 7.5957969 6 8.2167969 L 6 9.0410156 C 2.608 9.3180156 0 10.985 0 13 L 4.9726562 13 C 4.6689986 12.449922 4.7486357 11.731833 5.2109375 11.269531 L 8 8.4804688 L 8 8.2167969 C 9.102 7.5957969 10 6.048 10 4.5 C 10 2.015 10 0 7 0 z M 9 11.509766 L 8.2167969 12.292969 L 8.9238281 13 L 9 13 L 9 11.509766 z " />
<g
id="g2"
transform="matrix(0.51568847,0,0,0.51568847,5.8471254,8.4255678)">
<path
d="m 18.938,-1 h -6 c -0.412,0 -0.989,0.239 -1.28,0.53 L 4.219,6.969 c -0.292,0.292 -0.292,0.769 0,1.061 l 6.439,6.439 c 0.292,0.292 0.769,0.292 1.061,0 L 19.158,7.03 c 0.292,-0.292 0.53,-0.868 0.53,-1.28 v -6 c 0,-0.412 -0.337,-0.75 -0.75,-0.75 z m -3.75,6 c -0.828,0 -1.5,-0.672 -1.5,-1.5 0,-0.828 0.672,-1.5 1.5,-1.5 0.828,0 1.5,0.672 1.5,1.5 0,0.828 -0.672,1.5 -1.5,1.5 z"
id="path1-5" />
<path
d="m 1.688,7.5 8.5,-8.5 h -1.25 c -0.412,0 -0.989,0.239 -1.28,0.53 L 0.219,6.969 c -0.292,0.292 -0.292,0.769 0,1.061 l 6.439,6.439 c 0.292,0.292 0.769,0.292 1.061,0 l 0.47,-0.47 -6.5,-6.5 z"
id="path2-3" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -26,6 +26,11 @@
>Entity Health</a> >Entity Health</a>
</li> </li>
</ul> </ul>
<button
class="button"
@click="initCaches"
><Icon icon="database-refresh" />Reload caches</button>
</nav> </nav>
<div class="content"> <div class="content">
@@ -37,7 +42,17 @@
<script setup> <script setup>
import { inject } from 'vue'; import { inject } from 'vue';
import { post } from '#/src/api.js';
const pageContext = inject('pageContext'); const pageContext = inject('pageContext');
async function initCaches() {
await post('/caches', null, {
successFeedback: 'Reloaded caches',
errorFeedback: 'Failed to reload caches',
appendErrorMessage: true,
});
}
</script> </script>
<style scoped> <style scoped>
@@ -50,6 +65,7 @@ const pageContext = inject('pageContext');
.nav { .nav {
display: flex; display: flex;
justify-content: space-between;
padding: 1rem 1rem .75rem 1rem; padding: 1rem 1rem .75rem 1rem;
border-bottom: solid 1px var(--shadow-weak-30); border-bottom: solid 1px var(--shadow-weak-30);
margin-bottom: .25rem; margin-bottom: .25rem;

View File

@@ -29,15 +29,19 @@
:href="campaign.url || campaign.affiliate?.url" :href="campaign.url || campaign.affiliate?.url"
target="_blank" target="_blank"
class="campaign" class="campaign"
:style="{ 'background-image': backdrop && `url(${bannerSrc})` }"
:class="{ backdrop }"
data-umami-event="campaign-click" data-umami-event="campaign-click"
:data-umami-event-campaign-id="`${campaign.entity.slug}-${campaign.id}`" :data-umami-event-campaign-id="`${campaign.entity.slug}-${campaign.id}`"
> >
<img <div class="campaign-overlay">
:src="bannerSrc" <img
:width="campaign.banner.width" :src="bannerSrc"
:height="campaign.banner.height" :width="campaign.banner.width"
class="campaign-banner" :height="campaign.banner.height"
> class="campaign-banner"
>
</div>
</a> </a>
</template> </template>
@@ -52,12 +56,17 @@ const props = defineProps({
type: Object, type: Object,
default: null, default: null,
}, },
backdrop: {
type: Boolean,
default: false,
},
}); });
// console.log(props.campaign); // console.log(props.campaign);
const bannerSrc = (() => { const bannerSrc = (() => {
if (props.campaign.banner) { if (props.campaign.banner) {
// if (props.campaign.banner.entity.type === 'network' || props.campaign.banner.entity.isIndependent || !props.campaign.banner.entity.parent) {
if (props.campaign.banner.entity.type === 'network' || !props.campaign.banner.entity.parent) { if (props.campaign.banner.entity.type === 'network' || !props.campaign.banner.entity.parent) {
return `/banners/${props.campaign.banner.entity.slug}/${props.campaign.banner.id}.${props.campaign.banner.type || 'jpg'}`; return `/banners/${props.campaign.banner.entity.slug}/${props.campaign.banner.id}.${props.campaign.banner.type || 'jpg'}`;
} }
@@ -76,7 +85,10 @@ const bannerSrc = (() => {
height: 100%; height: 100%;
display: flex; display: flex;
justify-content: center; justify-content: center;
/* align-items: center; */ border-radius: .25rem;
overflow: hidden;
background-size: cover;
background-position: center;
} }
.frame { .frame {
@@ -84,14 +96,37 @@ const bannerSrc = (() => {
overflow: hidden; overflow: hidden;
} }
.campaign-overlay {
width: 100%;
height: 100%;
display: flex;
box-sizing: border-box;
justify-content: center;
align-items: center;
}
.campaign-banner { .campaign-banner {
width: auto;
height: auto; height: auto;
max-height: 100%; max-height: 100%;
max-width: 100%; max-width: 100%;
object-fit: contain; object-fit: contain;
} }
.backdrop {
.campaign-overlay {
padding: 4px; /* margin for drop shadow */
backdrop-filter: blur(1rem) saturate(70%) brightness(125%);
}
.dark .campaign-overlay {
backdrop-filter: blur(1rem) saturate(70%) brightness(75%);
}
.campaign-banner {
filter: drop-shadow(0 0 2px var(--shadow-weak-20));
}
}
.restricted { .restricted {
height: 100%; height: 100%;
display: flex; display: flex;

View File

@@ -1,43 +1,56 @@
<template> <template>
<ul <div class="tags-sections">
class="tags nolist" <div
:class="{ disabled: !editing.has(item.key) }" v-for="actorTags in tags"
> :key="`tags-${actorTags.actor?.slug || 'scene'}`"
<li class="tags-section"
v-for="tag in [...item.value, ...newTags]"
:key="`tag-${tag.id}`"
class="tag"
:class="{ deleted: edits.tags && !edits.tags.some((tagId) => tagId === tag.id) }"
> >
<span class="tag-name">{{ tag.name }}</span> <ul
class="tags nolist"
<Icon :class="{ disabled: !editing.has(item.key) }"
v-if="edits.tags && !edits.tags.some((tagId) => tagId === tag.id)"
icon="checkmark"
class="add"
@click="emit('tags', edits.tags.concat(tag.id))"
/>
<Icon
v-else
icon="cross2"
class="remove"
@click="emit('tags', edits.tags.filter((tagId) => tagId !== tag.id))"
/>
</li>
<li class="new">
<TagSearch
:disabled="!editing.has(item.key)"
@tag="addTag"
> >
<Icon <li
icon="plus3" v-if="actorTags.actor"
class="add" class="tags-actor"
/> >{{ actorTags.actor.name }}:</li>
</TagSearch>
</li> <li
</ul> v-for="tag in [...actorTags.tags, ...newTags.filter((newTag) => newTag.actorId === actorTags.actorId)]"
:key="`tag-${tag.id}`"
class="tag"
:class="{ deleted: edits.tags && !edits.tags.some((sceneTag) => sceneTag.id === tag.id && sceneTag.actorId === actorTags.actorId) }"
>
<span class="tag-name">{{ tag.name }}</span>
<Icon
v-if="edits.tags && !edits.tags.some((sceneTag) => sceneTag.id === tag.id && sceneTag.actorId === actorTags.actorId)"
icon="checkmark"
class="add"
@click="emit('tags', edits.tags.concat(tag))"
/>
<Icon
v-else
icon="cross2"
class="remove"
@click="emit('tags', edits.tags.filter((sceneTag) => !(sceneTag.id === tag.id && sceneTag.actorId === actorTags.actorId)))"
/>
</li>
<li class="new">
<TagSearch
:disabled="!editing.has(item.key)"
@tag="(tag) => addTag(tag, actorTags.actor)"
>
<Icon
icon="plus3"
class="add"
/>
</TagSearch>
</li>
</ul>
</div>
</div>
</template> </template>
<script setup> <script setup>
@@ -70,8 +83,23 @@ const props = defineProps({
const emit = defineEmits(['tags']); const emit = defineEmits(['tags']);
function addTag(tag) { const tags = [
if (props.edits.tags.some((tagId) => tagId === tag.id)) { {
tags: props.item.value.filter((tag) => tag.actorId === null),
actor: null,
actorId: null,
},
...props.scene.actors.map((actor) => ({
tags: props.item.value.filter((tag) => tag.actorId === actor.id),
actor,
actorId: actor?.id || null,
})),
];
function addTag(newTag, actor) {
const actorId = actor?.id || null;
if (props.edits.tags.some((sceneTag) => sceneTag.id === newTag.id && sceneTag.actorId === actorId)) {
events.emit('feedback', { events.emit('feedback', {
type: 'error', type: 'error',
message: 'Tag already added', message: 'Tag already added',
@@ -80,9 +108,13 @@ function addTag(tag) {
return; return;
} }
newTags.value = newTags.value.concat(tag); const newTagWithActorId = {
...newTag,
actorId,
};
emit('tags', props.edits.tags.concat(tag.id)); newTags.value = newTags.value.concat(newTagWithActorId);
emit('tags', props.edits.tags.concat(newTagWithActorId));
} }
watch(() => props.scene, () => { newTags.value = []; }); watch(() => props.scene, () => { newTags.value = []; });
@@ -116,7 +148,7 @@ watch(() => props.scene, () => { newTags.value = []; });
align-items: center; align-items: center;
margin-left: .25rem; margin-left: .25rem;
&:hover { &:hover .icon {
box-shadow: 0 0 3px var(--shadow-weak-20); box-shadow: 0 0 3px var(--shadow-weak-20);
} }
@@ -129,6 +161,18 @@ watch(() => props.scene, () => { newTags.value = []; });
} }
} }
.tags-sections {
display: flex;
flex-direction: column;
gap: .75rem;
margin: .5rem 0;
}
.tags-actor {
margin-right: .5rem;
font-weight: bold;
}
.tag { .tag {
display: flex; display: flex;
align-items: stretch; align-items: stretch;
@@ -145,9 +189,11 @@ watch(() => props.scene, () => { newTags.value = []; });
.tag, .tag,
.new { .new {
.remove, .remove,
.add { .add,
.actor {
height: auto; height: auto;
padding: .25rem .3rem; padding: .25rem .3rem;
margin-left: .25rem;
border-radius: .25rem; border-radius: .25rem;
fill: var(--highlight-strong-10); fill: var(--highlight-strong-10);

View File

@@ -12,14 +12,32 @@
<Icon icon="search" /> <Icon icon="search" />
</label> </label>
<!--
<div
v-show="showActorTags"
v-tooltip="'Tags relevant to the selected actors'"
class="filter-sort order noselect"
@click="showActorTags = false"
>
<Icon icon="user-tags" />
</div>
<div
v-show="!showActorTags"
v-tooltip="'All tags'"
class="filter-sort order noselect"
@click="showActorTags = true"
>
<Icon icon="price-tags" />
</div>
-->
<div <div
v-show="order === 'priority'" v-show="order === 'priority'"
class="filter-sort order noselect" class="filter-sort order noselect"
@click="order = 'count'" @click="order = 'count'"
> >
<Icon <Icon icon="star" />
icon="star"
/>
</div> </div>
<div <div
@@ -115,16 +133,22 @@ const props = defineProps({
type: Array, type: Array,
default: () => [], default: () => [],
}, },
actorTags: {
type: Array,
default: () => [],
},
}); });
const emit = defineEmits(['update']); const emit = defineEmits(['update']);
const { pageProps } = inject('pageContext');
// const { tag: pageTag, actor: pageActor } = pageProps;
const { tag: pageTag } = pageProps;
const search = ref(''); const search = ref('');
const searchRegexp = computed(() => new RegExp(search.value, 'i')); const searchRegexp = computed(() => new RegExp(search.value, 'i'));
const order = ref('priority'); const order = ref('priority');
// const showActorTags = ref(true);
const { pageProps } = inject('pageContext');
const { tag: pageTag } = pageProps;
const priorityTags = [ const priorityTags = [
'anal', 'anal',
@@ -148,8 +172,15 @@ const priorityTags = [
]; ];
const groupedTags = computed(() => { const groupedTags = computed(() => {
const selected = props.tags.filter((tag) => props.filters.tags.includes(tag.slug)); /*
const filtered = props.tags.filter((tag) => !props.filters.tags.includes(tag.slug) const tags = showActorTags.value && props.actorTags && (props.filters.actors.length > 0 || pageActor)
? props.actorTags
: props.tags;
*/
const tags = props.tags;
const selected = tags.filter((tag) => props.filters.tags.includes(tag.slug));
const filtered = tags.filter((tag) => !props.filters.tags.includes(tag.slug)
&& tag.id !== pageTag?.id && tag.id !== pageTag?.id
&& searchRegexp.value.test(tag.name)); && searchRegexp.value.test(tag.name));

View File

@@ -2,6 +2,14 @@
<footer class="footer"> <footer class="footer">
<span class="footer-segment">© traxxx</span> <span class="footer-segment">© traxxx</span>
<a
v-if="env.links.matrix"
:href="env.links.matrix"
target="_blank"
rel="noopener noreferrer"
class="footer-segment footer-link nolink matrix"
><Icon icon="matrix-full" /></a>
<a <a
v-if="env.links.discord" v-if="env.links.discord"
:href="env.links.discord" :href="env.links.discord"
@@ -56,7 +64,8 @@ const { env } = pageContext;
} }
} }
.discord .icon { .discord .icon,
.matrix .icon {
height: 1.25rem; height: 1.25rem;
width: 3.5rem; width: 3.5rem;
fill: var(--glass-strong-10); fill: var(--glass-strong-10);

View File

@@ -78,9 +78,9 @@
<div <div
v-for="photo in [ v-for="photo in [
...(coversInAlbum ? release.covers : []), ...(coversInAlbum ? release.covers : []),
...(release.chapters?.map((chapter) => chapter.poster).filter(Boolean) || []),
...release.photos, ...release.photos,
...release.caps, ...release.caps,
...(release.chapters?.map((chapter) => chapter.poster).filter(Boolean) || []),
...(release.teaser?.mime.type === 'image' ? [release.poster] : []), ...(release.teaser?.mime.type === 'image' ? [release.poster] : []),
]" ]"
:key="`photo-${photo.id}`" :key="`photo-${photo.id}`"

View File

@@ -3,6 +3,7 @@
<Campaign <Campaign
v-if="campaigns?.pagination" v-if="campaigns?.pagination"
:campaign="campaigns.pagination" :campaign="campaigns.pagination"
class="campaign-pagination"
/> />
<div <div
@@ -301,6 +302,10 @@ function getPath(page) {
padding: 0 1rem; padding: 0 1rem;
} }
:deep(.campaign-pagination) .campaign-banner {
width: auto;
}
@media(--small-10) { @media(--small-10) {
.campaign { .campaign {
padding: 0; padding: 0;

View File

@@ -52,6 +52,13 @@
v-tooltip="'Duration'" v-tooltip="'Duration'"
class="chapter-duration" class="chapter-duration"
><Icon icon="stopwatch" />{{ formatDuration(chapter.duration) }}</span> ><Icon icon="stopwatch" />{{ formatDuration(chapter.duration) }}</span>
<time
v-if="chapter.date"
v-tooltip="formatDate(chapter.date, 'yyyy-MM-dd hh:mm')"
:datetime="chapter.date"
class="chapter-date"
>{{ formatDate(chapter.date, 'MMM d') }}</time>
</span> </span>
<div class="chapter-info"> <div class="chapter-info">
@@ -87,7 +94,7 @@
import { computed } from 'vue'; import { computed } from 'vue';
import getPath from '#/src/get-path.js'; import getPath from '#/src/get-path.js';
import { formatDuration } from '#/utils/format.js'; import { formatDuration, formatDate } from '#/utils/format.js';
const props = defineProps({ const props = defineProps({
chapters: { chapters: {
@@ -138,9 +145,9 @@ const timeline = computed(() => {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 0 .5rem; padding: 0 .75rem;
border-radius: 0 0 .25rem .25rem; border-radius: 0 0 .25rem .25rem;
margin: 0 0 .5rem 0; margin: 0 0 .75rem 0;
color: var(--text-light); color: var(--text-light);
background: var(--grey-dark-40); background: var(--grey-dark-40);
font-size: .8rem; font-size: .8rem;
@@ -164,7 +171,7 @@ const timeline = computed(() => {
} }
.chapter-info { .chapter-info {
padding: 0 .5rem; padding: 0 .75rem;
font-size: 1rem; font-size: 1rem;
} }
@@ -181,6 +188,7 @@ const timeline = computed(() => {
} }
.chapter-description { .chapter-description {
text-align: justify;
line-height: 1.5; line-height: 1.5;
} }

View File

@@ -34,6 +34,7 @@
<TagsFilter <TagsFilter
:filters="filters" :filters="filters"
:tags="aggTags" :tags="aggTags"
:actor-tags="aggActorTags"
@update="updateFilter" @update="updateFilter"
/> />
@@ -65,6 +66,7 @@
<Campaign <Campaign
v-if="campaigns?.meta" v-if="campaigns?.meta"
:campaign="campaigns.meta" :campaign="campaigns.meta"
class="campaign-meta"
/> />
<div class="views"> <div class="views">
@@ -155,6 +157,7 @@
<Campaign <Campaign
v-if="campaigns?.scope" v-if="campaigns?.scope"
:campaign="campaigns.scope" :campaign="campaigns.scope"
class="campaign-scope"
/> />
</nav> </nav>
@@ -165,7 +168,10 @@
v-if="item === 'campaign' && sceneCampaign" v-if="item === 'campaign' && sceneCampaign"
:key="`campaign-${item.id}`" :key="`campaign-${item.id}`"
> >
<Campaign :campaign="sceneCampaign" /> <Campaign
:campaign="sceneCampaign"
:backdrop="true"
/>
</li> </li>
<li <li
@@ -258,6 +264,7 @@ const scenes = ref(pageProps.scenes);
const aggYears = ref(pageProps.aggYears || []); const aggYears = ref(pageProps.aggYears || []);
const aggActors = ref(pageProps.aggActors || []); const aggActors = ref(pageProps.aggActors || []);
const aggTags = ref(pageProps.aggTags || []); const aggTags = ref(pageProps.aggTags || []);
const aggActorTags = ref(pageProps.aggActorTags || []);
const aggChannels = ref(pageProps.aggChannels || []); const aggChannels = ref(pageProps.aggChannels || []);
const currentPage = ref(Number(routeParams.page)); const currentPage = ref(Number(routeParams.page));
@@ -358,6 +365,7 @@ async function search(options = {}) {
aggYears.value = res.aggYears; aggYears.value = res.aggYears;
aggActors.value = res.aggActors; aggActors.value = res.aggActors;
aggTags.value = res.aggTags; aggTags.value = res.aggTags;
aggActorTags.value = res.aggActorTags;
aggChannels.value = res.aggChannels; aggChannels.value = res.aggChannels;
total.value = res.total; total.value = res.total;
@@ -431,11 +439,11 @@ function setView(newView) {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
gap: .5rem; gap: .5rem;
padding: .5rem 1rem 1rem 1rem; padding: .5rem 1rem 1rem 1rem;
}
:deep(.campaign) .campaign-banner { :deep(.campaign-meta) .campaign-banner,
border-radius: .25rem; :deep(.campaign-scope) .campaign-banner {
box-shadow: 0 0 3px var(--shadow-weak-20); width: auto;
}
} }
.scopes { .scopes {

View File

@@ -81,7 +81,7 @@
<ul <ul
class="row tags nolist" class="row tags nolist"
:title="scene.tags.map((tag) => tag.name).join(', ')" :title="sceneTags.map((tag) => tag.name).join(', ')"
> >
<li <li
v-if="scene.shootId" v-if="scene.shootId"
@@ -94,9 +94,10 @@
</li> </li>
<li <li
v-for="tag in scene.tags" v-for="tag in sceneTags"
:key="`tag-${scene.id}-${tag.id}`" :key="`tag-${scene.id}-${tag.id}`"
class="tag" class="tag"
:class="{ piss: tag.slug === 'pissing' }"
> >
<Link <Link
:href="`/tag/${tag.slug}`" :href="`/tag/${tag.slug}`"
@@ -130,11 +131,14 @@ const props = defineProps({
}); });
const pageContext = inject('pageContext'); const pageContext = inject('pageContext');
const { user, restriction } = pageContext; const { user } = pageContext;
const pageStash = pageContext.pageProps.stash; const pageStash = pageContext.pageProps.stash;
const currentStash = pageStash || pageContext.assets?.primaryStash; const currentStash = pageStash || pageContext.assets?.primaryStash;
const priorityTags = props.scene.tags.map((tag) => tag.name).slice(0, 2); const tagsById = Object.fromEntries(props.scene.tags.map((tag) => [tag.id, tag]));
const sceneTags = Array.from(new Set(props.scene.tags.map((tag) => tag.id))).map((tagId) => tagsById[tagId]);
const priorityTags = sceneTags.map((tag) => tag.name).slice(0, 2);
const favorited = ref(props.scene.stashes.some((sceneStash) => sceneStash.id === currentStash?.id)); const favorited = ref(props.scene.stashes.some((sceneStash) => sceneStash.id === currentStash?.id));
</script> </script>
@@ -263,6 +267,12 @@ const favorited = ref(props.scene.stashes.some((sceneStash) => sceneStash.id ===
font-size: .75rem; font-size: .75rem;
} }
/*
.tag.piss {
color: var(--text-piss);
}
*/
.shoot { .shoot {
font-size: .75rem; font-size: .75rem;
font-weight: bold; font-weight: bold;

View File

@@ -41,6 +41,7 @@ const cookies = Cookies.withConverter({
const tags = { const tags = {
anal: 'anal', anal: 'anal',
'anal-prolapse': 'anal prolapse', 'anal-prolapse': 'anal prolapse',
'extreme-insertion': 'extreme insertion (oversized dildos)',
pissing: 'pissing', pissing: 'pissing',
gay: 'gay', gay: 'gay',
transsexual: 'transsexual', transsexual: 'transsexual',

View File

@@ -14,8 +14,8 @@
class="photo-container" class="photo-container"
> >
<img <img
:src="getPath(photo, 'thumbnail')" :src="getPath(photo, 'thumbnail', { local: true })"
:style="{ 'background-image': `url(${getPath(photo, 'lazy')})` }" :style="{ 'background-image': `url(${getPath(photo, 'lazy', { local: true })})` }"
:alt="photo.comment" :alt="photo.comment"
:width="photo.width" :width="photo.width"
:height="photo.height" :height="photo.height"

View File

@@ -174,6 +174,7 @@ module.exports = {
links: { links: {
content: 'mailto:content@traxxx.me', content: 'mailto:content@traxxx.me',
discord: 'https://discord.gg/gY6fnq6jJV', discord: 'https://discord.gg/gY6fnq6jJV',
matrix: 'https://matrix.to/#/#traxxx:matrix.unknown.name',
}, },
stashes: { stashes: {
nameLength: [2, 24], nameLength: [2, 24],

4
package-lock.json generated
View File

@@ -1,11 +1,11 @@
{ {
"name": "traxxx-web", "name": "traxxx-web",
"version": "0.46.3", "version": "0.47.12",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"version": "0.46.3", "version": "0.47.12",
"dependencies": { "dependencies": {
"@brillout/json-serializer": "^0.5.8", "@brillout/json-serializer": "^0.5.8",
"@dicebear/collection": "^7.0.5", "@dicebear/collection": "^7.0.5",

View File

@@ -92,7 +92,7 @@
"overrides": { "overrides": {
"vite": "$vite" "vite": "$vite"
}, },
"version": "0.46.3", "version": "0.47.12",
"imports": { "imports": {
"#/*": "./*.js" "#/*": "./*.js"
} }

View File

@@ -66,7 +66,16 @@
class="row" class="row"
> >
<div class="item-header"> <div class="item-header">
<div class="key">{{ item.label || item.key }}</div> <div class="key">
{{ item.label || item.key }}
<Icon
v-if="item.note"
v-tooltip="item.note"
icon="info2"
class="item-note"
/>
</div>
<div class="item-actions noselect"> <div class="item-actions noselect">
<Icon <Icon
@@ -443,6 +452,7 @@ const fields = computed(() => [
{ {
key: 'augmentation', key: 'augmentation',
type: 'augmentation', type: 'augmentation',
note: 'Provide explicit evidence, such as social media posts, visible scarring, or before/after. Avoid "it\'s obvious".',
value: { value: {
naturalBoobs: actor.value.naturalBoobs, naturalBoobs: actor.value.naturalBoobs,
boobsVolume: actor.value.boobsVolume, boobsVolume: actor.value.boobsVolume,
@@ -503,6 +513,7 @@ const fields = computed(() => [
{ {
key: 'piercings', key: 'piercings',
type: 'has', type: 'has',
note: 'Excludes earrings',
value: { value: {
has: actor.value.hasPiercings, has: actor.value.hasPiercings,
description: actor.value.piercings, description: actor.value.piercings,
@@ -685,10 +696,22 @@ async function submit() {
.key { .key {
width: 10rem; width: 10rem;
display: inline-flex;
align-items: center;
text-transform: capitalize; text-transform: capitalize;
font-weight: bold; font-weight: bold;
} }
.item-note{
fill: var(--glass);
padding: .5rem .75rem;
cursor: help;
&:hover {
fill: var(--primary);
}
}
.input { .input {
background: var(--background); background: var(--background);
} }

View File

@@ -87,6 +87,7 @@ const popularNetworks = [
'elegantangel', 'elegantangel',
'evilangel', 'evilangel',
'fakehub', 'fakehub',
'hentaied',
'hookuphotshot', 'hookuphotshot',
'hussiepass', 'hussiepass',
'julesjordan', 'julesjordan',

View File

@@ -44,7 +44,7 @@
</a> </a>
<a <a
v-if="user?.abilities.some((ability) => ability.plainUrls)" v-if="user?.abilities?.some((ability) => ability.plainUrls)"
:href="entity.url" :href="entity.url"
target="_blank" target="_blank"
rel="noopener" rel="noopener"

View File

@@ -140,20 +140,30 @@
</li> </li>
</ul> </ul>
<ul <div class="tags">
v-if="scene.tags.length > 0" <div
class="tags nolist" v-for="actorTags in tags"
> :key="`tags-${actorTags.actor?.slug || 'scene'}`"
<li class="tags-section"
v-for="tag in scene.tags"
:key="`tag-${tag.id}`"
> >
<Link <ul class="nolist">
:href="`/tag/${tag.slug}`" <li
class="tag nolink" v-if="actorTags.actor"
>{{ tag.name }}</Link> class="tags-actor"
</li> >{{ actorTags.actor.name }}:</li>
</ul>
<li
v-for="tag in actorTags.tags"
:key="`tag-${tag.id}`"
>
<Link
:href="`/tag/${tag.slug}`"
class="tag nolink"
>{{ tag.name }}</Link>
</li>
</ul>
</div>
</div>
<div <div
v-if="scene.movies.length > 0 || scene.series.length > 0" v-if="scene.movies.length > 0 || scene.series.length > 0"
@@ -249,12 +259,6 @@
</div> </div>
</div> </div>
<Chapters
v-if="scene.chapters.length > 0"
:chapters="scene.chapters"
class="section"
/>
<div <div
v-if="scene.description" v-if="scene.description"
class="section" class="section"
@@ -264,6 +268,18 @@
<p class="description">{{ scene.description }}</p> <p class="description">{{ scene.description }}</p>
</div> </div>
<section
v-if="scene.chapters.length > 0"
class="section"
>
<h3 class="heading">Chapters</h3>
<Chapters
:chapters="scene.chapters"
class="section"
/>
</section>
<div <div
v-if="campaigns?.scene" v-if="campaigns?.scene"
class="section" class="section"
@@ -434,6 +450,17 @@ const {
const { scene } = pageProps; const { scene } = pageProps;
const tags = [
{
tags: scene.tags.filter((tag) => tag.actorId === null),
actor: null,
},
...scene.actors.map((actor) => ({
actor,
tags: scene.tags.filter((tag) => tag.actorId === actor.id),
})),
].filter((actorTags) => actorTags.tags.length > 0);
const showSummaryDialog = ref(false); const showSummaryDialog = ref(false);
const qualities = { const qualities = {
@@ -634,6 +661,22 @@ function copySummary() {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.tags {
display: flex;
flex-wrap: wrap;
gap: .25rem 1rem;
}
.tags-section {
display: inline-flex;
gap: .25rem;
}
.tags-actor {
margin-right: .5rem;
font-weight: bold;
}
.actors { .actors {
display: flex; display: flex;
flex-grow: 1; flex-grow: 1;

View File

@@ -66,7 +66,16 @@
class="row" class="row"
> >
<div class="item-header"> <div class="item-header">
<div class="key">{{ item.label || item.key }}</div> <div class="key">
{{ item.label || item.key }}
<Icon
v-if="item.note"
v-tooltip="item.note"
icon="info2"
class="item-note"
/>
</div>
<div class="item-actions"> <div class="item-actions">
<Icon <Icon
@@ -252,6 +261,8 @@ const fields = computed(() => [
key: 'tags', key: 'tags',
type: 'tags', type: 'tags',
value: scene.value.tags.toSorted((tagA, tagB) => tagA.name.localeCompare(tagB.name)), value: scene.value.tags.toSorted((tagA, tagB) => tagA.name.localeCompare(tagB.name)),
simplify: false,
note: 'Actor-specific tags should only be used where confusion is reasonable, such as group scenes in which some perform anal, and some don\'t.',
}, },
{ {
key: 'movies', key: 'movies',
@@ -262,11 +273,13 @@ const fields = computed(() => [
key: 'title', key: 'title',
type: 'string', type: 'string',
value: scene.value.title, value: scene.value.title,
note: 'Do not correct language errors unless source was updated.',
}, },
{ {
key: 'description', key: 'description',
type: 'text', type: 'text',
value: scene.value.description, value: scene.value.description,
note: 'Do not correct language errors unless source was updated.',
}, },
{ {
key: 'date', key: 'date',
@@ -408,6 +421,8 @@ async function submit() {
.key { .key {
width: 8rem; width: 8rem;
display: inline-flex;
align-items: center;
text-transform: capitalize; text-transform: capitalize;
font-weight: bold; font-weight: bold;
} }
@@ -473,6 +488,16 @@ async function submit() {
} }
} }
.item-note{
fill: var(--glass);
padding: .5rem .75rem;
cursor: help;
&:hover {
fill: var(--primary);
}
}
.editor-footer { .editor-footer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -75,8 +75,8 @@
> >
<img <img
v-if="tag.poster" v-if="tag.poster"
:src="getPath(tag.poster, 'thumbnail')" :src="getPath(tag.poster, 'thumbnail', { local: true })"
:style="{ 'background-image': `url(/${getPath(tag.poster, 'lazy')})` }" :style="{ 'background-image': `url(${getPath(tag.poster, 'lazy', { local: true })})` }"
:title="tag.poster.comment" :title="tag.poster.comment"
class="thumb" class="thumb"
loading="lazy" loading="lazy"

View File

@@ -208,30 +208,34 @@ export function sortActorsByGender(actors, context = {}) {
export async function fetchActorsById(actorIds, options = {}, reqUser) { export async function fetchActorsById(actorIds, options = {}, reqUser) {
const [actors, profiles, photos, socials, stashes, alerts] = await Promise.all([ const [actors, profiles, photos, socials, stashes, alerts] = await Promise.all([
knex('actors') knex('actors')
.select( .select('actors.*')
'actors.*',
'actors_meta.stashed',
knex.raw('row_to_json(avatars) as avatar'),
'birth_countries.alpha2 as birth_country_alpha2',
knex.raw('COALESCE(birth_countries.alias, birth_countries.name) as birth_country_name'),
'residence_countries.alpha2 as residence_country_alpha2',
knex.raw('COALESCE(residence_countries.alias, residence_countries.name) as residence_country_name'),
knex.raw('row_to_json(entities) as entity'),
knex.raw('row_to_json(sfw_media) as sfw_avatar'),
)
.leftJoin('actors_meta', 'actors_meta.actor_id', 'actors.id')
.leftJoin('countries as birth_countries', 'birth_countries.alpha2', 'actors.birth_country_alpha2')
.leftJoin('countries as residence_countries', 'residence_countries.alpha2', 'actors.residence_country_alpha2')
.leftJoin('media as avatars', 'avatars.id', 'actors.avatar_media_id')
.leftJoin('media as sfw_media', 'sfw_media.id', 'avatars.sfw_media_id')
.leftJoin('entities', 'entities.id', 'actors.entity_id')
.whereIn('actors.id', actorIds) .whereIn('actors.id', actorIds)
.modify((builder) => { .modify((builder) => {
if (options.order) { if (options.order) {
builder.orderBy(...options.order); builder.orderBy(...options.order);
} }
})
.groupBy('actors.id', 'avatars.id', 'sfw_media.id', 'entities.id', 'actors_meta.stashed', 'birth_countries.alpha2', 'residence_countries.alpha2'), if (!options.shallow) {
builder
.select(
'actors_meta.stashed',
knex.raw('row_to_json(avatars) as avatar'),
'birth_countries.alpha2 as birth_country_alpha2',
knex.raw('COALESCE(birth_countries.alias, birth_countries.name) as birth_country_name'),
'residence_countries.alpha2 as residence_country_alpha2',
knex.raw('COALESCE(residence_countries.alias, residence_countries.name) as residence_country_name'),
knex.raw('row_to_json(entities) as entity'),
knex.raw('row_to_json(sfw_media) as sfw_avatar'),
)
.leftJoin('actors_meta', 'actors_meta.actor_id', 'actors.id')
.leftJoin('countries as birth_countries', 'birth_countries.alpha2', 'actors.birth_country_alpha2')
.leftJoin('countries as residence_countries', 'residence_countries.alpha2', 'actors.residence_country_alpha2')
.leftJoin('media as avatars', 'avatars.id', 'actors.avatar_media_id')
.leftJoin('media as sfw_media', 'sfw_media.id', 'avatars.sfw_media_id')
.leftJoin('entities', 'entities.id', 'actors.entity_id')
.groupBy('actors.id', 'avatars.id', 'sfw_media.id', 'entities.id', 'actors_meta.stashed', 'birth_countries.alpha2', 'residence_countries.alpha2');
}
}),
knex('actors_profiles') knex('actors_profiles')
.select( .select(
'actors_profiles.*', 'actors_profiles.*',
@@ -271,12 +275,15 @@ export async function fetchActorsById(actorIds, options = {}, reqUser) {
.where('user_id', reqUser.id) .where('user_id', reqUser.id)
.whereIn('actor_id', actorIds) .whereIn('actor_id', actorIds)
: [], : [],
]); ].slice(0, options.shallow ? 1 : -1));
if (options.order) { if (options.order) {
return actors.map((actorEntry) => curateActor(actorEntry, { return actors.map((actorEntry) => curateActor(actorEntry, {
stashes: stashes.filter((stash) => stash.actor_id === actorEntry.id), stashes: stashes?.filter((stash) => stash.actor_id === actorEntry.id),
alerts: alerts.filter((alert) => alert.actor_id === actorEntry.id), alerts: alerts?.filter((alert) => alert.actor_id === actorEntry.id),
profiles: profiles?.filter((profile) => profile.actor_id === actorEntry.id),
photos: photos?.filter((photo) => photo.actor_id === actorEntry.id),
socials: socials?.filter((social) => social.actor_id === actorEntry.id),
append: options.append, append: options.append,
})); }));
} }
@@ -290,11 +297,11 @@ export async function fetchActorsById(actorIds, options = {}, reqUser) {
} }
return curateActor(actor, { return curateActor(actor, {
stashes: stashes.filter((stash) => stash.actor_id === actor.id), stashes: stashes?.filter((stash) => stash.actor_id === actor.id),
alerts: alerts.filter((alert) => alert.actor_id === actor.id), alerts: alerts?.filter((alert) => alert.actor_id === actor.id),
profiles: profiles.filter((profile) => profile.actor_id === actor.id), profiles: profiles?.filter((profile) => profile.actor_id === actor.id),
photos: photos.filter((photo) => photo.actor_id === actor.id), photos: photos?.filter((photo) => photo.actor_id === actor.id),
socials: socials.filter((social) => social.actor_id === actor.id), socials: socials?.filter((social) => social.actor_id === actor.id),
append: options.append, append: options.append,
}); });
}).filter(Boolean); }).filter(Boolean);

View File

@@ -31,13 +31,17 @@ export function getAffiliateSceneUrl(scene) {
return watchUrl; return watchUrl;
} }
if (scene.affiliate.parameters.channelScenes === false && scene.channel && scene.affiliate.entityId !== scene.channel.id) {
return watchUrl;
}
if (scene.affiliate.parameters.dynamicScene) { if (scene.affiliate.parameters.dynamicScene) {
const scenePath = new URL(watchUrl).pathname; const scenePath = new URL(watchUrl).pathname;
return format(scene.affiliate.parameters.dynamicScene, { return format(scene.affiliate.parameters.dynamicScene, {
scenePath: scene.affiliate.parameters.prefixSlash scenePath: scene.affiliate.parameters.prefixSlash === false
? scenePath ? scenePath.replace(/^\//, '')
: scenePath.replace(/^\//, ''), : scenePath,
entryId: scene.entryId, entryId: scene.entryId,
}); });
} }
@@ -65,7 +69,7 @@ export function getAffiliateSceneUrl(scene) {
&& (!scene.channel.isIndependent || scene.channel.id === scene.affiliate.entityId)) { && (!scene.channel.isIndependent || scene.channel.id === scene.affiliate.entityId)) {
const { pathname, search } = new URL(watchUrl); const { pathname, search } = new URL(watchUrl);
return `${affiliateUrl}${pathname.replace(/^\/trial/, '')}${search}`; // replace needed for Jules Jordan, verify behavior on other sites return `${affiliateUrl}${pathname.replace(/^\/(trial|tour)/, '')}${search}`; // replace needed for Jules Jordan and HussiePass, verify behavior on other sites
} }
const affiliateUrlComponents = new URL(affiliateUrl); const affiliateUrlComponents = new URL(affiliateUrl);

View File

@@ -1,14 +1,8 @@
import initServer from './web/server.js'; import initServer from './web/server.js';
import { cacheTagIds } from './tags.js'; import { initCaches } from './cache.js';
import { cacheEntityIds } from './entities.js';
import { cacheCampaigns } from './campaigns.js';
async function init() { async function init() {
await Promise.all([ await initCaches();
cacheTagIds(),
cacheEntityIds(),
cacheCampaigns(),
]);
initServer(); initServer();
} }

View File

@@ -1,5 +1,9 @@
import redis from './redis.js'; import redis from './redis.js';
import { cacheTagIds } from './tags.js';
import { cacheEntityIds } from './entities.js';
import { cacheCampaigns } from './campaigns.js';
export async function getIdsBySlug(slugs, domain, toMap) { export async function getIdsBySlug(slugs, domain, toMap) {
if (!slugs) { if (!slugs) {
return []; return [];
@@ -25,3 +29,11 @@ export async function getIdsBySlug(slugs, domain, toMap) {
return ids.filter(Boolean); return ids.filter(Boolean);
} }
export async function initCaches() {
await Promise.all([
cacheTagIds(),
cacheEntityIds(),
cacheCampaigns(),
]);
}

View File

@@ -82,18 +82,27 @@ export async function getRandomCampaign(options = {}, context = {}, pass = 0) {
const validCampaigns = campaigns.filter((campaign) => { const validCampaigns = campaigns.filter((campaign) => {
if (options.minRatio && (!campaign.banner || campaign.banner.ratio < options.minRatio)) { if (options.minRatio && (!campaign.banner || campaign.banner.ratio < options.minRatio)) {
// too small
return false; return false;
} }
if (options.maxRatio && (!campaign.banner || campaign.banner.ratio > options.maxRatio)) { if (options.maxRatio && (!campaign.banner || campaign.banner.ratio > options.maxRatio)) {
// too big
return false; return false;
} }
if (options.entityIds && !options.entityIds.some((entityId) => campaign.entity.id === entityId || campaign.entity.parent?.id === entityId)) { if (options.entityIds && !options.entityIds.some((entityId) => campaign.entity.id === entityId || campaign.entity.parent?.id === entityId)) {
// this is an entity page, this campaign does not belong to this entity
return false;
}
if (campaign.affiliate?.parameters?.global === false && !options.entityIds) {
// this campaign should only show on entity page
return false; return false;
} }
if (context.tagFilter && campaign.banner && campaign.banner.tags.some((tag) => context.tagFilter.includes(tag) && !options.tagSlugs?.includes(tag))) { if (context.tagFilter && campaign.banner && campaign.banner.tags.some((tag) => context.tagFilter.includes(tag) && !options.tagSlugs?.includes(tag))) {
// wrong tag
return false; return false;
} }

View File

@@ -421,7 +421,7 @@ export async function fetchMovies(filters, rawOptions, reqUser, context) {
const channelCounts = options.aggregateChannels && countAggregations(result.aggregations?.channelIds); const channelCounts = options.aggregateChannels && countAggregations(result.aggregations?.channelIds);
const [aggActors, aggTags, aggChannels] = await Promise.all([ const [aggActors, aggTags, aggChannels] = await Promise.all([
options.aggregateActors ? fetchActorsById(result.aggregations.actorIds.map((bucket) => bucket.key), { order: ['slug', 'asc'], append: actorCounts }, reqUser) : [], options.aggregateActors ? fetchActorsById(result.aggregations.actorIds.map((bucket) => bucket.key), { shadllow: true, order: ['slug', 'asc'], append: actorCounts }, reqUser) : [],
options.aggregateTags ? fetchTagsById(result.aggregations.tagIds.map((bucket) => bucket.key), { order: [knex.raw('lower(name)'), 'asc'], append: tagCounts }, reqUser, context) : [], options.aggregateTags ? fetchTagsById(result.aggregations.tagIds.map((bucket) => bucket.key), { order: [knex.raw('lower(name)'), 'asc'], append: tagCounts }, reqUser, context) : [],
options.aggregateChannels ? fetchEntitiesById(result.aggregations.channelIds.map((bucket) => bucket.key), { order: ['slug', 'asc'], append: channelCounts }, reqUser, context) : [], options.aggregateChannels ? fetchEntitiesById(result.aggregations.channelIds.map((bucket) => bucket.key), { order: ['slug', 'asc'], append: channelCounts }, reqUser, context) : [],
]); ]);

View File

@@ -82,15 +82,18 @@ function curateScene(rawScene, assets, reqUser, context) {
slug: tag.slug, slug: tag.slug,
name: censor(tag.name, context.restriction), name: censor(tag.name, context.restriction),
priority: tag.priority, priority: tag.priority,
actorId: tag.actor_id,
})), })),
chapters: assets.chapters.map((chapter) => ({ chapters: assets.chapters.map((chapter) => ({
id: chapter.id, id: chapter.id,
title: chapter.title, title: chapter.title,
description: chapter.description,
time: chapter.time, time: chapter.time,
date: chapter.date,
duration: chapter.duration, duration: chapter.duration,
poster: context.restriction poster: context.restriction
? null ? null
: (chapter.chapter_poster), : curateMedia(chapter.chapter_poster, { type: 'poster' }),
tags: chapter.chapter_tags.map((tag) => ({ tags: chapter.chapter_tags.map((tag) => ({
id: tag.id, id: tag.id,
name: tag.name, name: tag.name,
@@ -135,7 +138,11 @@ function curateScene(rawScene, assets, reqUser, context) {
const isVideoRestricted = config.media.videoRestrictions.includes(curatedScene.channel.slug) || config.media.videoRestrictions.includes(`_${curatedScene.network?.slug}`); const isVideoRestricted = config.media.videoRestrictions.includes(curatedScene.channel.slug) || config.media.videoRestrictions.includes(`_${curatedScene.network?.slug}`);
if (!isVideoRestricted || reqUser?.abilities?.some((ability) => ability.trailerAccess)) { if (!isVideoRestricted || reqUser?.abilities?.some((ability) => ability.trailerAccess)) {
curatedScene.trailer = curateMedia(assets.trailer, { type: 'trailer', isRestricted: isVideoRestricted }); if (reqUser) {
// only show trailers to logged in users to curb S3 traffic
curatedScene.trailer = curateMedia(assets.trailer, { type: 'trailer', isRestricted: isVideoRestricted });
}
curatedScene.teaser = curateMedia(assets.teaser, { type: 'teaser', isRestricted: isVideoRestricted }); curatedScene.teaser = curateMedia(assets.teaser, { type: 'teaser', isRestricted: isVideoRestricted });
} }
@@ -212,7 +219,7 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) {
.whereIn('release_id', sceneIds) .whereIn('release_id', sceneIds)
.leftJoin('actors as directors', 'directors.id', 'releases_directors.director_id'), .leftJoin('actors as directors', 'directors.id', 'releases_directors.director_id'),
tags: knex('releases_tags') tags: knex('releases_tags')
.select('id', 'slug', 'name', 'priority', 'release_id') .select('tags.id', 'slug', 'name', 'priority', 'release_id', 'actor_id')
.leftJoin('tags', 'tags.id', 'releases_tags.tag_id') .leftJoin('tags', 'tags.id', 'releases_tags.tag_id')
.whereNotNull('tags.id') .whereNotNull('tags.id')
.whereIn('release_id', sceneIds) .whereIn('release_id', sceneIds)
@@ -395,6 +402,14 @@ function curateOptions(options) {
}; };
} }
function curateFacet(results, field, count = 'count(distinct id)') {
return results
.find((result) => result.columns[0][field] && result.columns[1][count])
?.data.map((row) => ({ key: row[field], doc_count: row[count] }))
.filter((row) => !!row.key)
|| [];
}
async function queryManticoreSql(filters, options, _reqUser) { async function queryManticoreSql(filters, options, _reqUser) {
const aggSize = config.database.manticore.maxAggregateSize; const aggSize = config.database.manticore.maxAggregateSize;
@@ -449,6 +464,13 @@ async function queryManticoreSql(filters, options, _reqUser) {
year(scenes.effective_date) as effective_year, year(scenes.effective_date) as effective_year,
weight() as _score weight() as _score
`)); `));
/*
// manticore only supports one joined table, so we can't use it inside stashes; probably not needed anyway (stashes only need global tags?)
builder
.leftJoin('scenes_tags', 'scenes_tags.scene_id', 'scenes_.id')
.groupBy('scenes.id');
*/
} }
if (filters.query) { if (filters.query) {
@@ -551,11 +573,17 @@ async function queryManticoreSql(filters, options, _reqUser) {
.limit(options.limit) .limit(options.limit)
.offset((options.page - 1) * options.limit), .offset((options.page - 1) * options.limit),
// option threads=1 fixes actors, but drastically slows down performance, wait for fix // option threads=1 fixes actors, but drastically slows down performance, wait for fix
yearsFacet: options.aggregateYears ? knex.raw('facet effective_year as years order by effective_year desc limit ?', [aggSize]) : null, yearsFacet: options.aggregateYears ? knex.raw('facet effective_year as years_facet order by effective_year desc limit ?', [aggSize]) : null,
actorsFacet: options.aggregateActors ? knex.raw('facet scenes.actor_ids order by count(*) desc limit ?', [aggSize]) : null, actorsFacet: options.aggregateActors ? knex.raw('facet scenes.actor_ids as actors_facet distinct id order by count(distinct id) desc limit ?', [aggSize]) : null,
tagsFacet: options.aggregateTags ? knex.raw('facet scenes.tag_ids order by count(*) desc limit ?', [aggSize]) : null, // don't facet tags associated to other actors, actor ID 0 means global
channelsFacet: options.aggregateChannels ? knex.raw('facet scenes.channel_id order by count(*) desc limit ?', [aggSize]) : null, tagsFacet: options.aggregateTags ? knex.raw('facet scenes.tag_ids as tags_facet order by count(distinct id) desc limit ?', [aggSize]) : null,
studiosFacet: options.aggregateChannels ? knex.raw('facet scenes.studio_id order by count(*) desc limit ?', [aggSize]) : null, /*
actorTagsFacet: options.aggregateTags && !filters.stashId // eslint-disable-line no-nested-ternary
? knex.raw(`facet IF(IN(scenes_tags.actor_id, ${[0, ...filters?.actorIds || []]}), scenes_tags.tag_id, 0) as actor_tags_facet distinct id order by count(distinct id) desc limit ?`, [aggSize])
: null,
*/
channelsFacet: options.aggregateChannels ? knex.raw('facet scenes.channel_id as channels_facet distinct id order by count(distinct id) desc limit ?', [aggSize]) : null,
studiosFacet: options.aggregateChannels ? knex.raw('facet scenes.studio_id as studios_facet distinct id order by count(distinct id) desc limit ?', [aggSize]) : null,
maxMatches: config.database.manticore.maxMatches, maxMatches: config.database.manticore.maxMatches,
maxQueryTime: config.database.manticore.maxQueryTime, maxQueryTime: config.database.manticore.maxQueryTime,
}).toString(); }).toString();
@@ -563,7 +591,9 @@ async function queryManticoreSql(filters, options, _reqUser) {
// manticore does not seem to accept table.column syntax if 'table' is primary (yet?), crude work-around // manticore does not seem to accept table.column syntax if 'table' is primary (yet?), crude work-around
const curatedSqlQuery = filters.stashId const curatedSqlQuery = filters.stashId
? sqlQuery ? sqlQuery
: sqlQuery.replace(/scenes\./g, ''); : sqlQuery
.replace(/scenes\./g, '')
.replace(/scenes_\./g, 'scenes.');
if (process.env.NODE_ENV === 'development' && argv.debug) { if (process.env.NODE_ENV === 'development' && argv.debug) {
console.log(curatedSqlQuery); console.log(curatedSqlQuery);
@@ -573,32 +603,12 @@ async function queryManticoreSql(filters, options, _reqUser) {
// console.log(util.inspect(results, null, Infinity)); // console.log(util.inspect(results, null, Infinity));
const years = results const years = curateFacet(results, 'years_facet', 'count(*)');
.find((result) => (result.columns[0].years || result.columns[0]['scenes.years']) && result.columns[1]['count(*)']) const actorIds = curateFacet(results, 'actors_facet');
?.data.map((row) => ({ key: row.years || row['scenes.years'], doc_count: row['count(*)'] })) const tagIds = curateFacet(results, 'tags_facet');
|| []; const actorTagIds = curateFacet(results, 'actor_tags_facet');
const channelIds = curateFacet(results, 'channels_facet');
const actorIds = results const studioIds = curateFacet(results, 'studios_facet');
.find((result) => (result.columns[0].actor_ids || result.columns[0]['scenes.actor_ids']) && result.columns[1]['count(*)'])
?.data.map((row) => ({ key: row.actor_ids || row['scenes.actor_ids'], doc_count: row['count(*)'] }))
|| [];
const tagIds = results
.find((result) => (result.columns[0].tag_ids || result.columns[0]['scenes.tag_ids']) && result.columns[1]['count(*)'])
?.data.map((row) => ({ key: row.tag_ids || row['scenes.tag_ids'], doc_count: row['count(*)'] }))
|| [];
const channelIds = results
.find((result) => (result.columns[0].channel_id || result.columns[0]['scenes.channel_id']) && result.columns[1]['count(*)'])
?.data.map((row) => ({ key: row.channel_id || row['scenes.channel_id'], doc_count: row['count(*)'] }))
|| [];
const studioIds = results
.find((result) => (result.columns[0].studio_id || result.columns[0]['scenes.studio_id']) && result.columns[1]['count(*)'])
?.data
.map((row) => ({ key: row.studio_id || row['scenes.studio_id'], doc_count: row['count(*)'] }))
.filter((row) => !!row.key)
|| [];
const total = Number(results.at(-1).data.find((entry) => entry.Variable_name === 'total_found')?.Value) || 0; const total = Number(results.at(-1).data.find((entry) => entry.Variable_name === 'total_found')?.Value) || 0;
@@ -609,6 +619,7 @@ async function queryManticoreSql(filters, options, _reqUser) {
years, years,
actorIds, actorIds,
tagIds, tagIds,
actorTagIds,
channelIds, channelIds,
studioIds, studioIds,
}, },
@@ -646,9 +657,10 @@ export async function fetchScenes(filters, rawOptions, reqUser, context) {
console.time('fetch aggregations'); console.time('fetch aggregations');
const [aggActors, aggTags, aggChannels] = await Promise.all([ const [aggActors, aggTags, aggActorTags, aggChannels] = await Promise.all([
options.aggregateActors ? fetchActorsById(result.aggregations.actorIds.map((bucket) => bucket.key), { order: ['slug', 'asc'], append: actorCounts }, reqUser) : [], options.aggregateActors ? fetchActorsById(result.aggregations.actorIds.map((bucket) => bucket.key), { shallow: true, order: ['slug', 'asc'], append: actorCounts }, reqUser) : [],
options.aggregateTags ? fetchTagsById(result.aggregations.tagIds.map((bucket) => bucket.key), { order: [knex.raw('lower(name)'), 'asc'], append: tagCounts }, reqUser, context) : [], options.aggregateTags ? fetchTagsById(result.aggregations.tagIds.map((bucket) => bucket.key), { order: [knex.raw('lower(name)'), 'asc'], append: tagCounts }, reqUser, context) : [],
options.aggregateTags ? fetchTagsById(result.aggregations.actorTagIds.map((bucket) => bucket.key), { order: [knex.raw('lower(name)'), 'asc'], append: tagCounts }, reqUser, context) : [],
options.aggregateChannels ? fetchEntitiesById(entityIds.map((bucket) => bucket.key), { order: ['slug', 'asc'], append: channelCounts }, reqUser, context) : [], options.aggregateChannels ? fetchEntitiesById(entityIds.map((bucket) => bucket.key), { order: ['slug', 'asc'], append: channelCounts }, reqUser, context) : [],
]); ]);
@@ -664,6 +676,7 @@ export async function fetchScenes(filters, rawOptions, reqUser, context) {
aggYears, aggYears,
aggActors, aggActors,
aggTags, aggTags,
aggActorTags,
aggChannels, aggChannels,
total: result.total, total: result.total,
limit: options.limit, limit: options.limit,
@@ -778,9 +791,10 @@ async function applySceneTagsDelta(sceneId, delta, trx) {
if (delta.value.length > 0) { if (delta.value.length > 0) {
await knexOwner('releases_tags') await knexOwner('releases_tags')
.insert(delta.value.map((tagId) => ({ .insert(delta.value.map((tag) => ({
release_id: sceneId, release_id: sceneId,
tag_id: tagId, tag_id: tag.id,
actor_id: tag.actorId,
source: 'editor', source: 'editor',
}))) })))
.transacting(trx); .transacting(trx);

View File

@@ -1,82 +0,0 @@
import { indexApi, utilsApi } from '../manticore.js';
import rawvideos from './movies.json' with { type: 'json' };
async function fetchvideos() {
const videos = rawvideos
.filter((video) => video.cast.length > 0
&& video.genres.length > 0
&& video.cast.every((actor) => actor.charCodeAt(0) >= 65)) // throw out videos with non-alphanumerical actor names
.map((video, index) => ({ id: index + 1, ...video }));
const actors = Array.from(new Set(videos.flatMap((video) => video.cast))).sort();
const genres = Array.from(new Set(videos.flatMap((video) => video.genres)));
return {
videos,
actors,
genres,
};
}
async function init() {
await utilsApi.sql('drop table if exists videos');
await utilsApi.sql('drop table if exists videos_liked');
await utilsApi.sql(`create table videos (
id int,
title text,
actor_ids multi,
actors text,
genre_ids multi,
genres text
)`);
await utilsApi.sql(`create table videos_liked (
id int,
user_id int,
video_id int
)`);
const { videos, actors, genres } = await fetchvideos();
const likedvideoIds = Array.from(new Set(Array.from({ length: 10.000 }, () => videos[Math.round(Math.random() * videos.length)].id)));
const docs = videos
.map((video) => ({
replace: {
index: 'videos',
id: video.id,
doc: {
title: video.title,
actor_ids: video.cast.map((actor) => actors.indexOf(actor)),
actors: video.cast.join(','),
genre_ids: video.genres.map((genre) => genres.indexOf(genre)),
genres: video.genres.join(','),
},
},
}))
.concat(likedvideoIds.map((videoId, index) => ({
replace: {
index: 'videos_liked',
id: index + 1,
doc: {
user_id: Math.floor(Math.random() * 51),
video_id: videoId,
},
},
})));
const data = await indexApi.bulk(docs.map((doc) => JSON.stringify(doc)).join('\n'));
console.log('data', data);
const result = await utilsApi.sql(`
select * from videos_liked
limit 10
`);
console.log(result[0].data);
console.log(result[1]);
}
init();

View File

@@ -1,174 +0,0 @@
// import config from 'config';
import { format } from 'date-fns';
import { faker } from '@faker-js/faker';
import { indexApi } from '../manticore.js';
import { knexOwner as knex } from '../knex.js';
import slugify from '../utils/slugify.js';
import chunk from '../utils/chunk.js';
async function fetchScenes() {
const scenes = await knex.raw(`
SELECT
releases.id AS id,
releases.title,
releases.created_at,
releases.date,
releases.shoot_id,
scenes_meta.stashed,
entities.id as channel_id,
entities.slug as channel_slug,
entities.name as channel_name,
parents.id as network_id,
parents.slug as network_slug,
parents.name as network_name,
COALESCE(JSON_AGG(DISTINCT (actors.id, actors.name)) FILTER (WHERE actors.id IS NOT NULL), '[]') as actors,
COALESCE(JSON_AGG(DISTINCT (tags.id, tags.name, tags.priority, tags_aliases.name)) FILTER (WHERE tags.id IS NOT NULL), '[]') as tags
FROM releases
LEFT JOIN scenes_meta ON scenes_meta.scene_id = releases.id
LEFT JOIN entities ON releases.entity_id = entities.id
LEFT JOIN entities AS parents ON parents.id = entities.parent_id
LEFT JOIN releases_actors AS local_actors ON local_actors.release_id = releases.id
LEFT JOIN releases_directors AS local_directors ON local_directors.release_id = releases.id
LEFT JOIN releases_tags AS local_tags ON local_tags.release_id = releases.id
LEFT JOIN actors ON local_actors.actor_id = actors.id
LEFT JOIN actors AS directors ON local_directors.director_id = directors.id
LEFT JOIN tags ON local_tags.tag_id = tags.id
LEFT JOIN tags as tags_aliases ON local_tags.tag_id = tags_aliases.alias_for AND tags_aliases.secondary = true
GROUP BY
releases.id,
releases.title,
releases.created_at,
releases.date,
releases.shoot_id,
scenes_meta.stashed,
entities.id,
entities.name,
entities.slug,
entities.alias,
parents.id,
parents.name,
parents.slug,
parents.alias;
`);
const actors = Object.fromEntries(scenes.rows.flatMap((row) => row.actors.map((actor) => [actor.f1, faker.person.fullName()])));
const tags = Object.fromEntries(scenes.rows.flatMap((row) => row.tags.map((tag) => [tag.f1, faker.word.adjective()])));
return scenes.rows.map((row) => {
const title = faker.lorem.lines(1);
const channelName = faker.company.name();
const channelSlug = slugify(channelName, '');
const networkName = faker.company.name();
const networkSlug = slugify(networkName, '');
const rowActors = row.actors.map((actor) => ({ f1: actor.f1, f2: actors[actor.f1] }));
const rowTags = row.tags.map((tag) => ({ f1: tag.f1, f2: tags[tag.f1], f3: tag.f3 }));
return {
...row,
title,
actors: rowActors,
tags: rowTags,
channel_name: channelName,
channel_slug: channelSlug,
network_name: networkName,
network_slug: networkSlug,
};
});
}
async function updateStashed(docs) {
await chunk(docs, 1000).reduce(async (chain, docsChunk) => {
await chain;
const sceneIds = docsChunk.map((doc) => doc.replace.id);
const stashes = await knex('stashes_scenes')
.select('stashes_scenes.id as stashed_id', 'stashes_scenes.scene_id', 'stashes.id as stash_id', 'stashes.user_id as user_id')
.leftJoin('stashes', 'stashes.id', 'stashes_scenes.stash_id')
.whereIn('scene_id', sceneIds);
if (stashes.length > 0) {
console.log(stashes);
}
const stashDocs = docsChunk.flatMap((doc) => {
const sceneStashes = stashes.filter((stash) => stash.scene_id === doc.replace.id);
if (sceneStashes.length === 0) {
return [];
}
const stashDoc = sceneStashes.map((stash) => ({
replace: {
index: 'scenes_stashed',
id: stash.stashed_id,
doc: {
// ...doc.replace.doc,
scene_id: doc.replace.id,
user_id: stash.user_id,
},
},
}));
return stashDoc;
});
console.log(stashDocs);
if (stashDocs.length > 0) {
await indexApi.bulk(stashDocs.map((doc) => JSON.stringify(doc)).join('\n'));
}
}, Promise.resolve());
}
async function init() {
const scenes = await fetchScenes();
const docs = scenes.map((scene) => {
const flatActors = scene.actors.flatMap((actor) => actor.f2.match(/[\w']+/g)); // match word characters to filter out brackets etc.
const flatTags = scene.tags.filter((tag) => tag.f3 > 6).flatMap((tag) => (tag.f4 ? `${tag.f2} ${tag.f4}` : tag.f2).match(/[\w']+/g)); // only make top tags searchable to minimize cluttered results
const filteredTitle = scene.title && [...flatActors, ...flatTags].reduce((accTitle, tag) => accTitle.replace(new RegExp(tag.replace(/[^\w\s]+/g, ''), 'i'), ''), scene.title).trim().replace(/\s{2,}/, ' ');
return {
replace: {
index: 'scenes',
id: scene.id,
doc: {
title: scene.title || undefined,
title_filtered: filteredTitle || undefined,
date: scene.date ? Math.round(scene.date.getTime() / 1000) : undefined,
created_at: Math.round(scene.created_at.getTime() / 1000),
effective_date: Math.round((scene.date || scene.created_at).getTime() / 1000),
// shoot_id: scene.shoot_id || undefined,
channel_id: scene.channel_id,
channel_slug: scene.channel_slug,
channel_name: scene.channel_name,
network_id: scene.network_id || undefined,
network_slug: scene.network_slug || undefined,
network_name: scene.network_name || undefined,
actor_ids: scene.actors.map((actor) => actor.f1),
actors: scene.actors.map((actor) => actor.f2).join(),
tag_ids: scene.tags.map((tag) => tag.f1),
tags: flatTags.join(' '),
meta: scene.date ? format(scene.date, 'y yy M MMM MMMM d') : undefined,
liked: scene.stashed || 0,
},
},
};
});
const data = await indexApi.bulk(docs.map((doc) => JSON.stringify(doc)).join('\n'));
await updateStashed(docs);
console.log('data', data);
knex.destroy();
}
init();

View File

@@ -1,50 +0,0 @@
import { indexApi, utilsApi } from '../manticore.js';
import { knexOwner as knex } from '../knex.js';
import chunk from '../utils/chunk.js';
async function syncStashes(domain = 'scene') {
await utilsApi.sql(`truncate table ${domain}s_stashed`);
const stashes = await knex(`stashes_${domain}s`)
.select(
`stashes_${domain}s.id as stashed_id`,
`stashes_${domain}s.${domain}_id`,
'stashes.id as stash_id',
'stashes.user_id as user_id',
`stashes_${domain}s.created_at as created_at`,
)
.leftJoin('stashes', 'stashes.id', `stashes_${domain}s.stash_id`);
await chunk(stashes, 1000).reduce(async (chain, stashChunk, index) => {
await chain;
const stashDocs = stashChunk.map((stash) => ({
replace: {
index: `${domain}s_stashed`,
id: stash.stashed_id,
doc: {
[`${domain}_id`]: stash[`${domain}_id`],
stash_id: stash.stash_id,
user_id: stash.user_id,
created_at: Math.round(stash.created_at.getTime() / 1000),
},
},
}));
await indexApi.bulk(stashDocs.map((doc) => JSON.stringify(doc)).join('\n'));
console.log(`Synced ${index * 1000 + stashChunk.length}/${stashes.length} ${domain} stashes`);
}, Promise.resolve());
}
async function init() {
await syncStashes('scene');
await syncStashes('actor');
await syncStashes('movie');
console.log('Done!');
knex.destroy();
}
init();

View File

@@ -11,7 +11,7 @@ export default async function initRestrictionHandler() {
const reader = await Reader.open('assets/GeoLite2-City.mmdb'); const reader = await Reader.open('assets/GeoLite2-City.mmdb');
function getRestriction(req) { function getRestriction(req) {
if (req.session.restriction && req.session.country && req.session.restrictionIp === req.userIp) { if (Object.hasOwn(req.session, 'restriction') && Object.hasOwn(req.session, 'country') && req.session.restrictionIp === req.userIp) {
return { return {
restriction: req.session.restriction, restriction: req.session.restriction,
country: req.session.country, country: req.session.country,
@@ -71,6 +71,10 @@ export default async function initRestrictionHandler() {
req.country = country; req.country = country;
} catch (error) { } catch (error) {
logger.error(`Failed Maxmind IP lookup for ${req.ip}: ${error.message}`); logger.error(`Failed Maxmind IP lookup for ${req.ip}: ${error.message}`);
req.session.restrictionIp = req.userIp;
req.session.restriction = 0;
req.session.country = null;
} }
next(); next();

View File

@@ -59,6 +59,7 @@ async function fetchScenesApi(req, res) {
aggYears, aggYears,
aggActors, aggActors,
aggTags, aggTags,
aggActorTags,
aggChannels, aggChannels,
limit, limit,
total, total,
@@ -77,6 +78,7 @@ async function fetchScenesApi(req, res) {
aggYears, aggYears,
aggActors, aggActors,
aggTags, aggTags,
aggActorTags,
aggChannels, aggChannels,
limit, limit,
total, total,

View File

@@ -41,6 +41,8 @@ import { router as userRouter } from './users.js';
import { router as stashesRouter } from './stashes.js'; import { router as stashesRouter } from './stashes.js';
import { router as alertsRouter } from './alerts.js'; import { router as alertsRouter } from './alerts.js';
import { initCachesApi } from './system.js';
import initLogger from '../logger.js'; import initLogger from '../logger.js';
const logger = initLogger(); const logger = initLogger();
@@ -158,6 +160,8 @@ export default async function initServer() {
// TAGS // TAGS
router.get('/api/tags', fetchTagsApi); router.get('/api/tags', fetchTagsApi);
router.post('/api/caches', initCachesApi);
if (config.apiAccess.graphqlEnabled) { if (config.apiAccess.graphqlEnabled) {
router.post('/graphql', graphqlApi); router.post('/graphql', graphqlApi);
} }

12
src/web/system.js Normal file
View File

@@ -0,0 +1,12 @@
import { HttpError } from '../errors.js';
import { initCaches } from '../cache.js';
export async function initCachesApi(req, res) {
if (req.user?.role !== 'admin') {
throw new HttpError('You must be an admin to initialize caches', 404);
}
await initCaches();
res.status(204).send();
}

2
static

Submodule static updated: 8f331b46e7...217845ef37