summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--flake.lock32
-rw-r--r--flake.nix21
-rw-r--r--meson.build2
-rwxr-xr-xscripts/run_valgrind.sh1
-rw-r--r--src/buffer/common_internal.h3
-rw-r--r--src/buffer/video.c36
-rw-r--r--src/codec/codec.h7
-rw-r--r--src/codec/ffmpeg/decoder.c6
-rw-r--r--src/codec/ffmpeg/demuxer.c6
-rw-r--r--src/fruits/cmc/ui/util/waveform.c2
-rw-r--r--src/fruits/cmc/ui/widgets/now_playing.c2
-rw-r--r--src/liana/handlers/codec_server.c2
-rw-r--r--src/liana/list.c3
-rw-r--r--src/liana/vcr.c2
-rw-r--r--src/libsink/desktop.c2
-rw-r--r--src/libsink/sink.c5
-rw-r--r--src/portal/py/base.py3
-rw-r--r--src/portal/py/modules/__init__.py17
-rw-r--r--src/portal/py/modules/twitter.py261
-rw-r--r--src/portal/py/modules/twitter_api.py269
-rw-r--r--src/portal/py/modules/twitter_common.py177
-rw-r--r--src/portal/py/modules/twitter_scrape.py191
-rw-r--r--src/portal/py/modules/youtube.py3
-rw-r--r--src/portal/py/tests/archive_query.py32
-rw-r--r--src/portal/py/tests/logger.py15
-rw-r--r--src/portal/py/tests/test.py8
-rw-r--r--src/portal/py/tests/touch_all.py40
-rw-r--r--src/portal/requirements.txt1
-rw-r--r--src/render/queue_libplacebo.c2
-rw-r--r--src/render/renderer_libplacebo.c21
-rw-r--r--src/screen/screen.c38
-rw-r--r--src/screen/view.c17
-rw-r--r--src/screen/view.h3
-rw-r--r--src/util/color_palette.c2
-rw-r--r--subprojects/libalabaster.wrap2
-rw-r--r--subprojects/libnaunet.wrap2
-rw-r--r--subprojects/miniaudio.wrap4
-rw-r--r--subprojects/packagefiles/miniaudio/meson.build2
-rw-r--r--subprojects/stela.wrap2
39 files changed, 667 insertions, 577 deletions
diff --git a/flake.lock b/flake.lock
index e6b6997..4fc758c 100644
--- a/flake.lock
+++ b/flake.lock
@@ -23,11 +23,11 @@
]
},
"locked": {
- "lastModified": 1750614446,
- "narHash": "sha256-6WH0aRFay79r775RuTqUcnoZNm6A4uHxU1sbcNIk63s=",
+ "lastModified": 1753294394,
+ "narHash": "sha256-1Dfgq09lHZ8AdYB2Deu/mYP1pMNpob8CgqT5Mzo44eI=",
"owner": "nix-community",
"repo": "home-manager",
- "rev": "7c35504839f915abec86a96435b881ead7eb6a2b",
+ "rev": "1fde6fb1be6cd5dc513dc1c287d69e4eb2de973e",
"type": "github"
},
"original": {
@@ -39,11 +39,11 @@
},
"nixos-hardware": {
"locked": {
- "lastModified": 1750431636,
- "narHash": "sha256-vnzzBDbCGvInmfn2ijC4HsIY/3W1CWbwS/YQoFgdgPg=",
+ "lastModified": 1753122741,
+ "narHash": "sha256-nFxE8lk9JvGelxClCmwuJYftbHqwnc01dRN4DVLUroM=",
"owner": "NixOS",
"repo": "nixos-hardware",
- "rev": "1552a9f4513f3f0ceedcf90320e48d3d47165712",
+ "rev": "cc66fddc6cb04ab479a1bb062f4d4da27c936a22",
"type": "github"
},
"original": {
@@ -59,11 +59,11 @@
"nixpkgs": "nixpkgs"
},
"locked": {
- "lastModified": 1749574455,
- "narHash": "sha256-fm2/8KPOYvvIAnNVtjDlTt/My00lIbZQ+LMrfQIWVzs=",
+ "lastModified": 1752682362,
+ "narHash": "sha256-ZNIpqCG/CfhmV+TgIeyO/XbhDjSWpwWokHM44j0Mn0w=",
"owner": "nix-community",
"repo": "NixOS-WSL",
- "rev": "917af390377c573932d84b5e31dd9f2c1b5c0f09",
+ "rev": "20001f9bf0aaf2b1c307e43a5eec8cf8f800fe14",
"type": "github"
},
"original": {
@@ -75,27 +75,27 @@
},
"nixpkgs": {
"locked": {
- "lastModified": 1749173751,
- "narHash": "sha256-ENY3y3v6S9ZmLDDLI3LUT8MXmfXg/fSt2eA4GCnMVCE=",
+ "lastModified": 1751792365,
+ "narHash": "sha256-J1kI6oAj25IG4EdVlg2hQz8NZTBNYvIS0l4wpr9KcUo=",
"owner": "NixOS",
"repo": "nixpkgs",
- "rev": "ed29f002b6d6e5e7e32590deb065c34a31dc3e91",
+ "rev": "1fd8bada0b6117e6c7eb54aad5813023eed37ccb",
"type": "github"
},
"original": {
"owner": "NixOS",
- "ref": "nixos-24.11",
+ "ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
- "lastModified": 1750506804,
- "narHash": "sha256-VLFNc4egNjovYVxDGyBYTrvVCgDYgENp5bVi9fPTDYc=",
+ "lastModified": 1752950548,
+ "narHash": "sha256-NS6BLD0lxOrnCiEOcvQCDVPXafX1/ek1dfJHX1nUIzc=",
"owner": "NixOS",
"repo": "nixpkgs",
- "rev": "4206c4cb56751df534751b058295ea61357bbbaa",
+ "rev": "c87b95e25065c028d31a94f06a62927d18763fdf",
"type": "github"
},
"original": {
diff --git a/flake.nix b/flake.nix
index 2605a20..72331f7 100644
--- a/flake.nix
+++ b/flake.nix
@@ -58,11 +58,11 @@
"aarch64-linux"
] (system:
function (import (nixpkgs.legacyPackages.${system}.applyPatches {
- src = nixpkgs;
- name = "nixpkgs-camu";
- patches = [
- ./env/nixpkgs-patches/289217.diff
- ];
+ src = nixpkgs;
+ name = "nixpkgs-camu";
+ patches = [
+ ./env/nixpkgs-patches/289217.diff
+ ];
}) {
inherit system;
inherit overlays;
@@ -124,7 +124,10 @@
python-pkgs.cython
]))
ffmpeg-headless # native input for ffprobe
- ] ++ lib.optionals (system == "x86_64-linux") [ ant jdk8 ];
+ ] ++ lib.optionals (system == "x86_64-linux") [
+ #jdk8
+ #ant
+ ];
buildInputs = [
(curl.override { websocketSupport = true; })
jansson
@@ -147,6 +150,7 @@
shaderc
libGL
vulkan-headers
+ vulkan-validation-layers
libxkbcommon
glib
dbus
@@ -194,6 +198,7 @@
perf-tools
valgrind
clang-tools
+ mypy
ruff
ty
kdePackages.kcachegrind
@@ -235,9 +240,13 @@
buildInputs = packages.${system}.default.buildInputs;
shellHook = ''
export SHELL="${pkgs.bashInteractive}/bin/bash"
+ # Vulkan ICDs
+ export XDG_DATA_DIRS="${pkgs.mesa}/share:$XDG_DATA_DIRS"
+ export __EGL_VENDOR_LIBRARY_DIRS="${pkgs.mesa}/share/glvnd/egl_vendor.d:$__EGL_VENDOR_LIBRARY_DIRS"
export NIX_BUILD_SHELL=$SHELL
export NIX_SHELL="camu-${system}+dev"
export PS1="(\$NIX_SHELL)"$PS1
+ echo -en '\033]2;'nix-shell: $NIX_SHELL'\007'
'';
};
diff --git a/meson.build b/meson.build
index fcd5ee3..5a9619f 100644
--- a/meson.build
+++ b/meson.build
@@ -87,6 +87,6 @@ if not meson.is_subproject()
subdir('src/fruits')
endif
-if get_option('tests').auto() and not meson.is_subproject()
+if get_option('tests').allowed() and not meson.is_subproject()
subdir('tests')
endif
diff --git a/scripts/run_valgrind.sh b/scripts/run_valgrind.sh
index c6ccd3d..c49d561 100755
--- a/scripts/run_valgrind.sh
+++ b/scripts/run_valgrind.sh
@@ -3,3 +3,4 @@ source ../scripts/python_env
#valgrind --leak-check=full --show-leak-kinds=definite ./src/fruits/cmv/cmv "$@"
valgrind --leak-check=full --show-leak-kinds=all ./src/fruits/cmv/cmv "$@"
#valgrind --leak-check=no --show-error-list=yes ./src/fruits/cmv/cmv "$@"
+#valgrind --tool=callgrind ./src/fruits/cmv/cmv "$@"
diff --git a/src/buffer/common_internal.h b/src/buffer/common_internal.h
index 11caddc..700e278 100644
--- a/src/buffer/common_internal.h
+++ b/src/buffer/common_internal.h
@@ -17,6 +17,5 @@ enum {
static inline bool frame_is_late(struct camu_clock *clock, f64 base, f64 pts, f64 duration)
{
- if (base == -1.0) base = camu_clock_get_base_pts(clock);
- return pts + duration < base;
+ return pts + duration < ((base == -1.0) ? camu_clock_get_base_pts(clock) : base);
}
diff --git a/src/buffer/video.c b/src/buffer/video.c
index 8bedbf6..ab61c2a 100644
--- a/src/buffer/video.c
+++ b/src/buffer/video.c
@@ -1,4 +1,5 @@
#define AL_LOG_SECTION "video_buffer"
+//#define AL_LOG_ENABLE_TRACE
#include <al/log.h>
#ifdef CAMU_HAVE_FFMPEG
@@ -14,10 +15,10 @@
// quite incompatible with very low frame rates.
// MARK_LOW is considered directly after reading a frame, so in other words,
// it will trigger at the point where there is about (LOW+1) frames left. Even at
-// something like 240fps that's still ~12 whole ms to uncork and produce a new frame.
-#define BUFFER_MARK_LOW 2
-#define BUFFER_MARK_BUFFERED 5 // Must be >1.
-#define BUFFER_MARK_HIGH 7
+// something like 240fps that's still ~(4*(LOW+1))ms to uncork and produce a new frame.
+#define BUFFER_MARK_LOW 3
+#define BUFFER_MARK_BUFFERED 6 // Must be >1.
+#define BUFFER_MARK_HIGH 8
#define BUFFER_MARK_RESET (BUFFER_MARK_HIGH * 2)
bool camu_video_buffer_init(struct camu_video_buffer *buf, struct camu_clock *clock)
@@ -75,7 +76,7 @@ bool camu_video_buffer_configure(struct camu_video_buffer *buf, struct camu_code
camu_video_format_copy(req, in);
if (fmt->width > 0 && fmt->height > 0 &&
#ifdef CAMU_VIDEO_BUFFER_REQUIRE_RGB
- (fmt->format != CAMU_PIXEL_FORMAT_RGBA && fmt->format != CAMU_PIXEL_FORMAT_RGB32)
+ (fmt->format != CAMU_PIXEL_FORMAT_RGBA && fmt->format != CAMU_PIXEL_FORMAT_RGB24 && fmt->format != CAMU_PIXEL_FORMAT_RGB32)
#else
(fmt->format == CAMU_PIXEL_FORMAT_PAL8)
#endif
@@ -84,10 +85,9 @@ bool camu_video_buffer_configure(struct camu_video_buffer *buf, struct camu_code
req->width = fmt->width;
req->height = fmt->height;
#ifdef CAMU_HAVE_FFMPEG
- //const AVPixFmtDescriptor *desc = av_pix_fmt_desc_get((enum AVPixelFormat)fmt->format);
- //bool has_alpha = (desc->flags & AV_PIX_FMT_FLAG_ALPHA) != 0;
- //req->format = CAMU_PIXEL_FORMAT_RGB32;
- req->format = CAMU_PIXEL_FORMAT_RGBA;
+ const AVPixFmtDescriptor *desc = av_pix_fmt_desc_get((enum AVPixelFormat)fmt->format);
+ bool has_alpha = (desc->flags & AV_PIX_FMT_FLAG_ALPHA) != 0;
+ req->format = has_alpha ? CAMU_PIXEL_FORMAT_RGB32 : CAMU_PIXEL_FORMAT_RGB24;
buf->scaler = camu_ff_scaler_create();
if (!buf->scaler->init(buf->scaler, &buf->fmt)) {
// Scaler will be freed in video_buffer_free().
@@ -143,8 +143,8 @@ static bool push_av_frame_internal(struct camu_video_buffer *buf, AVFrame *frame
{
AVStream *stream = buf->stream->av.stream;
f64 pts = frame->best_effort_timestamp * av_q2d(stream->time_base);
- f64 duration = camu_ff_frame_duration(frame) * av_q2d(stream->time_base);
f64 base_pts = al_atomic_load(f64)(&buf->pts, AL_ATOMIC_ACQUIRE);
+ f64 duration = camu_ff_frame_duration(frame) * av_q2d(stream->time_base);
if (!buf->single_frame && frame_is_late(buf->clock, base_pts, pts, duration)) {
return false;
}
@@ -228,10 +228,7 @@ bool camu_video_buffer_read(struct camu_video_buffer *buf, void *out, bool *weig
f64 base_pts = al_atomic_load(f64)(&buf->pts, AL_ATOMIC_ACQUIRE);
if (!buf->single_frame) {
f64 pts = camu_clock_get_pts(buf->clock, buf->latency, !buf->weighted_read);
- if (pts > base_pts) {
- base_pts = pts;
- al_atomic_store(f64)(&buf->pts, base_pts, AL_ATOMIC_RELEASE);
- }
+ if (pts > base_pts) base_pts = pts;
}
u8 flow = al_atomic_load(u8)(&buf->flow, AL_ATOMIC_ACQUIRE);
@@ -263,8 +260,15 @@ bool camu_video_buffer_read(struct camu_video_buffer *buf, void *out, bool *weig
}
if (ret == CAMU_QUEUE_OK) {
- *weighted = buf->weighted_read;
- buf->weighted_read = false;
+ if (UNLIKELY(buf->weighted_read)) {
+ *weighted = buf->weighted_read;
+ buf->weighted_read = false;
+ }
+ // Only updating buf->pts on QUEUE_OK affects the behavior when we fall completely behind.
+ // Setting it here will result in laggily displaying out of date frames as they come in.
+ // Setting it on every read() will cause the video to freeze.
+ // This option ignores sync but is easier for the user to understand what's happening.
+ al_atomic_store(f64)(&buf->pts, base_pts, AL_ATOMIC_RELEASE);
} else if (ret == CAMU_QUEUE_MORE) {
log_trace("Underrun.");
}
diff --git a/src/codec/codec.h b/src/codec/codec.h
index 3440e9f..25ec73e 100644
--- a/src/codec/codec.h
+++ b/src/codec/codec.h
@@ -59,7 +59,8 @@ enum {
enum {
CAMU_PIXEL_FORMAT_RGBA = AV_PIX_FMT_RGBA,
- CAMU_PIXEL_FORMAT_RGB = AV_PIX_FMT_RGB8,
+ CAMU_PIXEL_FORMAT_RGB8 = AV_PIX_FMT_RGB8,
+ CAMU_PIXEL_FORMAT_RGB24 = AV_PIX_FMT_RGB24,
CAMU_PIXEL_FORMAT_RGB32 = AV_PIX_FMT_RGB32,
CAMU_PIXEL_FORMAT_GREYA = AV_PIX_FMT_GRAY8A,
CAMU_PIXEL_FORMAT_GREY = AV_PIX_FMT_GRAY8,
@@ -380,7 +381,7 @@ static inline const char *camu_pixel_format_name(s32 format)
#else
switch (format) {
case CAMU_PIXEL_FORMAT_RGBA: return "rgba";
- case CAMU_PIXEL_FORMAT_RGB: return "rgb";
+ case CAMU_PIXEL_FORMAT_RGB24: return "rgb";
case CAMU_PIXEL_FORMAT_GREYA: return "grey + alpha";
case CAMU_PIXEL_FORMAT_GREY: return "grey";
default: return "(unknown)";
@@ -392,7 +393,7 @@ static inline s32 camu_pixel_format_from_channels(s32 channels)
{
switch (channels) {
case 4: return CAMU_PIXEL_FORMAT_RGBA;
- case 3: return CAMU_PIXEL_FORMAT_RGB;
+ case 3: return CAMU_PIXEL_FORMAT_RGB24;
case 2: return CAMU_PIXEL_FORMAT_GREYA;
case 1: return CAMU_PIXEL_FORMAT_GREY;
default: al_assert_and_return(CAMU_PIXEL_FORMAT_GREY);
diff --git a/src/codec/ffmpeg/decoder.c b/src/codec/ffmpeg/decoder.c
index 9693dd9..cfa1dc2 100644
--- a/src/codec/ffmpeg/decoder.c
+++ b/src/codec/ffmpeg/decoder.c
@@ -268,6 +268,7 @@ static bool ff_decoder_init(struct camu_decoder *dec, struct camu_renderer *rend
#ifdef CAMU_FF_DECODER_HWACCEL
if (is_video && av->hw_device_type != AV_HWDEVICE_TYPE_NONE) {
+ //av->codec_context->hwaccel_flags |= AV_HWACCEL_FLAG_IGNORE_LEVEL | AV_HWACCEL_FLAG_ALLOW_PROFILE_MISMATCH;
if (init_hwdevice_context(av, av->codec_context) == 0) {
av->renderer = renderer;
av->codec_context->opaque = av;
@@ -282,7 +283,7 @@ static bool ff_decoder_init(struct camu_decoder *dec, struct camu_renderer *rend
#endif
if (is_video) {
- av->thread_count = MIN(6, MAX(1, av_cpu_count() / 2));
+ av->thread_count = MIN(8, MAX(1, av_cpu_count() / 2));
#ifdef CAMU_FF_DECODER_HWACCEL
if (av->hw_device_type == AV_HWDEVICE_TYPE_NONE) {
#endif
@@ -298,6 +299,9 @@ static bool ff_decoder_init(struct camu_decoder *dec, struct camu_renderer *rend
#endif
}
+ //av->codec_context->flags |= AV_CODEC_FLAG_BITEXACT;
+ //av->codec_context->flags2 |= AV_CODEC_FLAG2_FAST;
+
if (!open_avcodec_internal(av, codec)) {
goto err;
}
diff --git a/src/codec/ffmpeg/demuxer.c b/src/codec/ffmpeg/demuxer.c
index 6560e0e..f05cfa9 100644
--- a/src/codec/ffmpeg/demuxer.c
+++ b/src/codec/ffmpeg/demuxer.c
@@ -80,7 +80,9 @@ static bool ff_demuxer_init(struct camu_demuxer *demux, struct cch_handle *handl
s64 default_duration = (av->format_context->duration < 0) ? 0 : av->format_context->duration;
av->duration = 0;
- // @TODO: Handle no-duration video.
+ // @TODO:
+ // - 1 frame .gif.
+ // - No-duration video. (ex: data/test_resources/no_duration.mkv)
#define GUESS_STREAM_IS_IMAGE(stream) \
((stream->duration >= 0 && stream->nb_frames <= 1 && \
(stream->avg_frame_rate.den == 0 && stream->r_frame_rate.den > 0)) || \
@@ -113,7 +115,7 @@ static bool ff_demuxer_init(struct camu_demuxer *demux, struct cch_handle *handl
}
if (stream->start_time == AV_NOPTS_VALUE) stream->start_time = 0;
} else if (type != AVMEDIA_TYPE_ATTACHMENT) {
- continue;
+ type = AVMEDIA_TYPE_UNKNOWN; // Mapped to CAMU_STREAM_UNKNOWN.
}
al_array_push(av->demux.streams, ((struct camu_codec_stream){
.mode = CAMU_FFMPEG_COMPAT,
diff --git a/src/fruits/cmc/ui/util/waveform.c b/src/fruits/cmc/ui/util/waveform.c
index 9983280..576cc37 100644
--- a/src/fruits/cmc/ui/util/waveform.c
+++ b/src/fruits/cmc/ui/util/waveform.c
@@ -1,5 +1,5 @@
#include <al/log.h>
-#include <math.h>
+#include <al/math.h>
#include "waveform.h"
#define WITHIN_RMS(low, high, rms) \
diff --git a/src/fruits/cmc/ui/widgets/now_playing.c b/src/fruits/cmc/ui/widgets/now_playing.c
index 748462b..355240b 100644
--- a/src/fruits/cmc/ui/widgets/now_playing.c
+++ b/src/fruits/cmc/ui/widgets/now_playing.c
@@ -1,4 +1,4 @@
-#include <math.h>
+#include <al/math.h>
#include "../../../../liana/list.h"
#include "../../../../util/print_time.h"
diff --git a/src/liana/handlers/codec_server.c b/src/liana/handlers/codec_server.c
index 3b7d31e..c324481 100644
--- a/src/liana/handlers/codec_server.c
+++ b/src/liana/handlers/codec_server.c
@@ -58,7 +58,7 @@ static void codec_server_write_info(struct lia_server_handler *handler, struct n
nn_packet_write_u8(packet, stream->mode);
nn_packet_write_u8(packet, stream->type);
nn_packet_write_u64(packet, stream->duration);
- // stream->index = i is currently expected but not necessary.
+ // stream->index = i is currently expected but shouldn't be (and isn't) relied upon anywhere.
al_assert(stream->index == (s32)i);
nn_packet_write_s32(packet, stream->index);
switch (stream->mode) {
diff --git a/src/liana/list.c b/src/liana/list.c
index 610ee80..42fcb91 100644
--- a/src/liana/list.c
+++ b/src/liana/list.c
@@ -287,6 +287,9 @@ static bool handle_skipto(struct lia_list *list, s32 sequence, s32 index)
// Target was never started before.
target->start = at;
} else {
+ // @TODO: I've observed this tripping when target->ended is true.
+ // Possibly related to clients having network latency and/or high video processing load.
+ // Maybe also exclusive to SINK_LOCAL mode.
// Target is paused or static.
al_assert(target->duration == 0 || target->paused_at != LIANA_TIMESTAMP_INVALID);
}
diff --git a/src/liana/vcr.c b/src/liana/vcr.c
index 352fb03..518311a 100644
--- a/src/liana/vcr.c
+++ b/src/liana/vcr.c
@@ -67,7 +67,7 @@ static nn_thread_result NNWT_THREADCALL vcr_track_thread(void *userdata)
struct lia_vcr_track *track = (struct lia_vcr_track *)userdata;
struct lia_vcr *vcr = track->vcr;
const char thread_name[16] = "\0"; // 16 = limit.
- al_snprintf((char *)thread_name, sizeof(thread_name), "vcr:%hu", vcr->node_id);
+ al_snprintf((char *)thread_name, sizeof(thread_name), "vcr:%hu_%hu", vcr->node_id, track->stream->index);
nn_thread_set_name(thread_name);
s32 state;
diff --git a/src/libsink/desktop.c b/src/libsink/desktop.c
index 6ef0562..12008fd 100644
--- a/src/libsink/desktop.c
+++ b/src/libsink/desktop.c
@@ -141,7 +141,7 @@ static void log_sink_status(struct camu_desktop *c)
offset += al_snprintf(status + offset, sizeof(status) - offset, "]");
status[offset] = '\0';
- log_info("%s, window size: %ux%u", status, c->scr.width, c->scr.height);
+ log_info("%s, window size: %ux%u, volume: %f", status, c->scr.width, c->scr.height, c->mixer.volume);
return;
notplaying:
log_info("Not playing, window size: %ux%u", c->scr.width, c->scr.height);
diff --git a/src/libsink/sink.c b/src/libsink/sink.c
index 6565cf5..2bcc943 100644
--- a/src/libsink/sink.c
+++ b/src/libsink/sink.c
@@ -1540,11 +1540,16 @@ static struct nn_rpc_command commands[] = {
static void identify_callback(void *userdata, struct nn_rpc_connection *conn, struct nn_packet *packet)
{
struct camu_sink *sink = (struct camu_sink *)userdata;
+#ifndef CAMU_DIRECT_MODE
if (sink->type == NNWT_SOCKET_UNIX) {
log_info("Sink connected to %.*s.", al_str_x(&sink->addr));
} else {
log_info("Sink connected to %.*s:%hu.", al_str_x(&sink->addr), sink->port);
}
+#else
+ (void)sink;
+ log_info("Sink locally bridged to server.");
+#endif
nn_packet_stream_return_packet(conn->stream, packet);
}
diff --git a/src/portal/py/base.py b/src/portal/py/base.py
index f40bc00..a146e8c 100644
--- a/src/portal/py/base.py
+++ b/src/portal/py/base.py
@@ -110,8 +110,7 @@ class Module():
def add_raw_response(self, data: ParsedJson) -> str:
with self.mutex:
dump = json.dumps(data, separators=(',', ':'), indent=None)
- hash = blake3(dump.encode('utf-8')).digest()
- hash_str = binascii.hexlify(hash).decode('ascii')
+ hash_str = binascii.hexlify(blake3(dump.encode('utf-8')).digest()).decode('ascii')
self.raw_responses[hash_str] = dump
return hash_str
diff --git a/src/portal/py/modules/__init__.py b/src/portal/py/modules/__init__.py
index 8a1e3cb..a9b50eb 100644
--- a/src/portal/py/modules/__init__.py
+++ b/src/portal/py/modules/__init__.py
@@ -1,21 +1,20 @@
import config
from modules.youtube import YoutubeModule
-from modules.twitter import TwitterScrapeModule
-from modules.pixiv_app import PixivAppModule
-from modules.pixiv_web import PixivWebModule
+#from modules.twitter_scrape import TwitterScrapeModule
+#from modules.twitter import TwitterModule
+#from modules.pixiv_app import PixivAppModule
+#from modules.pixiv_web import PixivWebModule
#from modules.fanbox import FanboxModule
#from modules.instagram import InstagramModule
#from modules.patreon import PatreonModule
ALL_MODULES = {
'youtube': (YoutubeModule(), []),
-# 'twitter': (TwitterModule(
-# config.TWITTER_ACCESS_TOKEN, config.TWITTER_ACCESS_TOKEN_SECRET,
-# config.TWITTER_CONSUMER_TOKEN, config.TWITTER_CONSUMER_TOKEN_SECRET), []),
- 'twitter': (TwitterScrapeModule(config.TWITTER_COOKIES_PATH), []),
- 'pixiv_web': (PixivWebModule(config.PIXIV_SESSID, config.PIXIV_USERID), []),
- 'pixiv_app': (PixivAppModule(config.PIXIV_REFRESH_TOKEN), []),
+# 'twitter_scrape': (TwitterScrapeModule(config.TWITTER_COOKIES_PATH), []),
+# 'twitter': (TwitterModule(config.TWITTER_COOKIES_PATH), []),
+# 'pixiv_web': (PixivWebModule(config.PIXIV_SESSID, config.PIXIV_USERID), []),
+# 'pixiv_app': (PixivAppModule(config.PIXIV_REFRESH_TOKEN), []),
# 'fanbox': (FanboxModule(config.FANBOX_SESSID, config.FANBOX_CF_CLEARANCE), []),
# 'instagram': (InstagramModule(
# config.INSTAGRAM_USER_AGENT, config.INSTAGRAM_SETTINGS_PATH,
diff --git a/src/portal/py/modules/twitter.py b/src/portal/py/modules/twitter.py
index 93286c3..f756d13 100644
--- a/src/portal/py/modules/twitter.py
+++ b/src/portal/py/modules/twitter.py
@@ -1,178 +1,121 @@
-import log
+import json
+import httpx
import http.cookiejar
from typing import Optional, Any, Iterator
-from datetime import timezone
-from base import USER_AGENT, Search, Module
-from post import Url, PostType, Date, DateType, DateMeta, PostRef, Post, User, Media, Image, Video
+from base import USER_AGENT, Search, Module, ParsedJson, Method
+from post import Post
from query_parser import QueryParser
-from modules.common import get_current_utc_time
-
-# GraphQL API
-from snscrape.base import ScraperException
-from snscrape.modules import twitter
-from snscrape.modules.twitter import (Tweet, TweetRef, Tombstone, UserRef,
- TwitterSearchScraper, TwitterSearchScraperMode, TwitterProfileScraper, TwitterUserScraper, TwitterTweetScraper)
-
-class TwitterScrapeBase(Search):
- # Posts per emulated page because snscrape returns an iterator.
- POSTS_PER_PAGE = 8
-
- # Don't allow requests for a page more than this amount past the current page.
- REACH_LIMIT = 4
+from modules.twitter_common import parse_graphql_raw_response
+class TwitterBase(Search):
def __init__(self, userdata: Any) -> None:
super().__init__()
- self.module: TwitterScrapeModule = userdata
- self.iterator: Iterator[Tweet | TweetRef | Tombstone]
- self.page: int = 0
-
- def create_media_url(self, url: str) -> Url:
- format = url.find('format=')
- ext = None
- if format >= 0:
- ext = url[format + 7:]
- amp = ext.find('&')
- if amp >= 0:
- ext = ext[:amp]
- return Url(url, ext)
-
- def parse_media(self, data: list[twitter.Medium]) -> dict[str, Media]:
- media = {}
- for index, m in enumerate(data):
- if isinstance(m, twitter.Photo):
- media[str(index)] = Image(url=self.create_media_url(m.fullUrl), thumbnail_url=self.create_media_url(m.previewUrl))
- elif isinstance(m, twitter.Video) or isinstance(m, twitter.Gif):
- videos = sorted(m.variants, key=lambda x: x.bitrate if x.bitrate else 0, reverse=True)
- media[str(index)] = Video(url=self.create_media_url(videos[0].url), thumbnail_url=self.create_media_url(m.thumbnailUrl))
- return media
-
- def create_post(self, tweet: Tweet | TweetRef | Tombstone) -> Post:
- kwargs = {}
- kwargs['module'] = 'twitter_scrape'
- kwargs['unique_id'] = f'twitter:t:{tweet.id}'
- if isinstance(tweet, TweetRef) or isinstance(tweet, Tombstone):
- kwargs['type'] = PostType.TOMBSTONE
- return Post(**kwargs)
- kwargs['raw_responses'] = tweet.rawResponses
- kwargs['url'] = tweet.url
- create_date = tweet.date.replace(tzinfo=timezone.utc).timestamp()
- current_date = get_current_utc_time().timestamp()
- kwargs['dates'] = [
- Date(DateType.CREATED, (create_date, create_date), DateMeta.NONE),
- Date(DateType.RETRIEVED, (current_date, current_date), DateMeta.NONE)
- ]
- author = User(unique_id=f'twitter:u:{tweet.user.id}')
- if not isinstance(tweet.user, UserRef):
- author.username = tweet.user.username
- if tweet.user.displayname:
- author.display_name = tweet.user.displayname
- if tweet.user.profileImageUrl:
- author.profile_picture_url = self.create_media_url(tweet.user.profileImageUrl)
- kwargs['author'] = author
- if tweet.retweetedTweet:
- kwargs['type'] = PostType.REPOST
- kwargs['post'] = PostRef(f'twitter:t:{tweet.retweetedTweet.id}')
- return Post(**kwargs)
- kwargs['type'] = PostType.POST
- kwargs['text'] = tweet.rawContent
- kwargs['likes'] = tweet.likeCount
- kwargs['reposts'] = tweet.retweetCount
- kwargs['quotes'] = tweet.quoteCount
- kwargs['comments'] = tweet.replyCount
- kwargs['views'] = tweet.viewCount
- if tweet.media:
- kwargs['media'] = self.parse_media(tweet.media)
- if tweet.inReplyToTweetId:
- kwargs['in_reply_to'] = PostRef(f'twitter:t:{tweet.inReplyToTweetId}')
- if tweet.quotedTweet:
- kwargs['quoted'] = PostRef(f'twitter:t:{tweet.quotedTweet.id}')
- return Post(**kwargs)
+ self.module: TwitterModule = userdata
+ self.cursor: Optional[dict] = None
- def add_post_to_page(self, num: int, item: Post | User) -> None:
- self.module.add_to_map(item.unique_id, item)
- self.pages[num].append(item.unique_id)
+def build_params(params: dict) -> dict:
+ return json.dumps(params, separators=(',', ':'), indent=None)
- def step_iterator_for_page(self, num: int) -> bool:
- for _ in range(0, self.POSTS_PER_PAGE):
- try:
- tweet = next(self.iterator)
- except StopIteration:
- self.completed = True
- break
- except ScraperException as e:
- log.error(repr(e))
- continue
- self.add_post_to_page(num, self.create_post(tweet))
- if isinstance(tweet, TweetRef) or isinstance(tweet, Tombstone):
- continue
- if tweet.retweetedTweet:
- retweet = self.create_post(tweet.retweetedTweet)
- self.module.add_to_map(retweet.unique_id, retweet)
- if tweet.quotedTweet:
- quote = self.create_post(tweet.quotedTweet)
- self.module.add_to_map(quote.unique_id, quote)
- return len(self.pages[num]) > 0
-
- def request_page(self, num: int) -> bool:
- if num - self.page >= self.REACH_LIMIT:
- return False
- request_satisfied = False
- for index in range(self.page, num + 1):
- if self.completed:
- break
- if index in self.pages:
- continue
- self.pages[index] = []
- if not self.step_iterator_for_page(index):
- break
- self.page = index
- if index == num:
- request_satisfied = True
- return request_satisfied
-
-class TwitterScrapeSearch(TwitterScrapeBase):
+class TwitterUser(TwitterBase):
def __init__(self, userdata: Any, arg: str) -> None:
super().__init__(userdata)
- self.iterator = TwitterSearchScraper(arg, mode=TwitterSearchScraperMode.TOP, cookies=self.module.cookies).get_items()
+ self.user_id = ''
-class TwitterScrapeUser(TwitterScrapeBase):
- def __init__(self, userdata: Any, arg: str) -> None:
- super().__init__(userdata)
- if arg.startswith('@'):
- arg = arg[1:]
- self.iterator = TwitterUserScraper(arg, cookies=self.module.cookies).get_items()
+ def check_api_response(self, response: Optional[httpx.Response]) -> Optional[ParsedJson]:
+ if not response:
+ return None
+ try:
+ obj = response.json()
+ except JSONDecodeError as e:
+ log.error(f'JSONDecodeError: {e}.')
+ return None
+ return obj
-class TwitterScrapeProfile(TwitterScrapeBase):
- def __init__(self, userdata: Any, arg: str) -> None:
- super().__init__(userdata)
- if arg.startswith('@'):
- arg = arg[1:]
- self.iterator = TwitterProfileScraper(arg, cookies=self.module.cookies).get_items()
+ def do_request(self) -> Optional[ParsedJson]:
+ variables = {
+ 'userId': f'{self.user_id}',
+ 'count': 20,
+ 'includePromotedContent': False,
+ 'withClientEventToken': False,
+ 'withBirdwatchNotes': False,
+ 'withVoice': True
+ }
+ if self.cursor:
+ variables['cursor'] = self.cursor
+ features = {
+ 'rweb_video_screen_enabled': False,
+ 'payments_enabled': False,
+ 'profile_label_improvements_pcf_label_in_post_enabled': True,
+ 'rweb_tipjar_consumption_enabled': True,
+ 'verified_phone_label_enabled': False,
+ 'creator_subscriptions_tweet_preview_api_enabled': True,
+ 'responsive_web_graphql_timeline_navigation_enabled': True,
+ 'responsive_web_graphql_skip_user_profile_image_extensions_enabled': False,
+ 'premium_content_api_read_enabled': False,
+ 'communities_web_enable_tweet_community_results_fetch': True,
+ 'c9s_tweet_anatomy_moderator_badge_enabled': True,
+ 'responsive_web_grok_analyze_button_fetch_trends_enabled': False,
+ 'responsive_web_grok_analyze_post_followups_enabled': True,
+ 'responsive_web_jetfuel_frame': True,
+ 'responsive_web_grok_share_attachment_enabled': True,
+ 'articles_preview_enabled': True,
+ 'responsive_web_edit_tweet_api_enabled': True,
+ 'graphql_is_translatable_rweb_tweet_is_translatable_enabled': True,
+ 'view_counts_everywhere_api_enabled': True,
+ 'longform_notetweets_consumption_enabled': True,
+ 'responsive_web_twitter_article_tweet_consumption_enabled': True,
+ 'tweet_awards_web_tipping_enabled': False,
+ 'responsive_web_grok_show_grok_translated_post': False,
+ 'responsive_web_grok_analysis_button_from_backend': True,
+ 'creator_subscriptions_quote_tweet_preview_enabled': False,
+ 'freedom_of_speech_not_reach_fetch_enabled': True,
+ 'standardized_nudges_misinfo': True,
+ 'tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled': True,
+ 'longform_notetweets_rich_text_read_enabled': True,
+ 'longform_notetweets_inline_media_enabled': True,
+ 'responsive_web_grok_image_annotation_enabled': True,
+ 'responsive_web_grok_community_note_auto_translation_is_enabled': False,
+ 'responsive_web_enhance_cards_enabled': False
+ }
+ fieldToggles = {
+ 'withArticlePlainText': False
+ }
+ params = {
+ 'variables': build_params(variables),
+ 'features': build_params(features),
+ 'fieldToggles': build_params(fieldToggles)
+ }
+ url = "https://x.com/i/api/graphql/DdW3L2nfGW6FZDcSSSG4yg/UserMedia"
+ return self.check_api_response(self.module.do_request(Method.GET, url, params=params))
-class TwitterScrapeTweet(TwitterScrapeBase):
- def __init__(self, userdata: Any, arg: str) -> None:
- super().__init__(userdata)
- if arg.startswith('https://'):
- arg = arg[arg.rfind('/') + 1:]
- question = arg.find('?')
- if question >= 0:
- arg = arg[:question]
- self.iterator = TwitterTweetScraper(arg, cookies=self.module.cookies).get_items()
+ #print(json.dumps(media, indent=4))
+ def request_page(self, num: int) -> bool:
+ media = self.do_request()
+ #print(json.dumps(media, indent=4))
+ response_hash = self.module.add_raw_response(media)
+ tps = parse_graphql_raw_response(media, response_hash, None, self.module)
+ if not tps.cursor:
+ self.completed = True
+ if len(tps.page) > 0:
+ self.cursor = tps.cursor
+ else:
+ self.completed = True
+ return False
+ self.pages[num] = tps.page
+ return True
-class TwitterScrapeModule(Module):
+class TwitterModule(Module):
def __init__(self, cookies_path: str) -> None:
super().__init__()
- self.parser: QueryParser = QueryParser(self, 'search')
- self.parser.add_command('search', TwitterScrapeSearch)
- self.parser.add_command('user', TwitterScrapeUser)
- self.parser.add_command('profile', TwitterScrapeProfile)
- self.parser.add_command('tweet', TwitterScrapeTweet)
- if cookies_path:
- cookie_jar = http.cookiejar.MozillaCookieJar()
- cookie_jar.load(filename=cookies_path, ignore_expires=True)
- for c in cookie_jar:
- if c.value:
- self.cookies[c.name] = c.value
+ self.parser: QueryParser = QueryParser(self, 'user')
+ self.parser.add_command('user', TwitterUser)
+ # @TODO: Cookie import from browser.
+ #if cookies_path:
+ # cookie_jar = http.cookiejar.MozillaCookieJar()
+ # cookie_jar.load(filename=cookies_path, ignore_expires=True)
+ # for c in cookie_jar:
+ # if c.value:
+ # self.cookies[c.name] = c.value
def search(self, query: str, *extra_args: Any) -> Optional[Search]:
return self.parser.parse_query(query)
diff --git a/src/portal/py/modules/twitter_api.py b/src/portal/py/modules/twitter_api.py
deleted file mode 100644
index 6d04855..0000000
--- a/src/portal/py/modules/twitter_api.py
+++ /dev/null
@@ -1,269 +0,0 @@
-from base import Search, Module
-from query_parser import QueryParser
-
-# Official Twitter API
-from twitter import Twitter2, TwitterError, OAuth
-
-# Quite incomplete API based backend. Should *not* be used for archiving.
-# HOLD: I can't test this without paying $100 for the X API WTFFF.
-'''
-class TwitterBase(Search):
- ALL_PARAMS = {
- 'tweet.fields': 'attachments,author_id,context_annotations,conversation_id,created_at,entities,geo,id,in_reply_to_user_id,lang,public_metrics,possibly_sensitive,referenced_tweets,reply_settings,source,text,withheld',
- 'user.fields': 'created_at,description,entities,id,location,name,pinned_tweet_id,profile_image_url,protected,public_metrics,url,username,verified,withheld',
- 'media.fields': 'duration_ms,height,media_key,preview_image_url,type,url,width,public_metrics',
- 'place.fields': 'contained_within,country,country_code,full_name,geo,id,name,place_type',
- 'poll.fields': 'duration_minutes,end_datetime,id,options,voting_status',
- }
- ALL_EXPRESSIONS = 'author_id,referenced_tweets.id,referenced_tweets.id.author_id,entities.mentions.username,attachments.poll_ids,attachments.media_keys,in_reply_to_user_id,geo.place_id'
-
- SOME_PARAMS = {
- 'tweet.fields': 'attachments,author_id,text,entities,referenced_tweets',
- 'user.fields': 'id,name,profile_image_url,url,username',
- 'media.fields': 'duration_ms,height,media_key,preview_image_url,type,url,width,public_metrics',
- 'place.fields': '',
- 'poll.fields': '',
- }
- SOME_EXPRESSIONS = 'author_id,referenced_tweets.id,referenced_tweets.id.author_id,entities.mentions.username,attachments.media_keys,in_reply_to_user_id'
-
- def __init__(self, userdata, arg):
- super().__init__()
- self.provider = userdata
- self.params = self.SOME_PARAMS.copy()
- self.media_map = {}
- self.user_id = None
- self.pagination_page = 0
- self.pagination_token = None
- self.arg = arg
-
- def add_attachemnts(self, l, r, data):
- if 'media_keys' not in data:
- return
- for m in data['media_keys']:
- if m in r:
- continue
- r.append(m)
- if m in self.media_map:
- l.append(Image(url=self.media_map[m], thumbnail_url=''))
-
- def add_entity_urls(self, data):
- if 'urls' in data:
- for u in data['urls']:
- if 'media_key' in u and u['media_key'] not in self.media_map:
- self.media_map[u['media_key']] = u['expanded_url']
-
- def make_post(self, tweet):
- print(json.dumps(tweet, indent=4))
- media = []
- repeats = []
- if 'referenced_tweets' in t:
- print(len(referenced_tweets))
- #for rt in t['referenced_tweets']:
- # if rt['id'] in self.tweet_map:
- # rrt = self.tweet_map[rt['id']]
- # if 'entities' in rrt:
- # self.add_entity_urls(rrt['entities'])
- # if 'attachments' in rrt:
- # self.add_attachemnts(media, repeats, rrt['attachments'])
- if 'attachments' in t:
- self.add_attachemnts(media, repeats, t['attachments'])
- unique_id = 'twitter:t:{}'.format(t['id'])
- url = 'https://twitter.com/{}/status/{}'.format(t['author_id'], t['id'])
- kwargs = {}
- kwargs['unique_id'] = unique_id
- kwargs['raw_responses'] = { 'tweet' : json.dumps(t) }
- kwargs['url'] = url
- kwargs['author'] = User(unique_id='twitter:u:{}'.format(t['author_id']))
- kwargs['title'] = ''
- kwargs['text'] = t['text']
- kwargs['media'] = media
- return Post(**kwargs)
-
- def add_items_from_search(self, data):
- if 'includes' in data:
- includes = data['includes']
- if 'media' in includes:
- for i in includes['media']:
- if 'url' in i:
- self.media_map[i['media_key']] = i['url']
- #if 'tweets' in includes:
- # for t in includes['tweets']:
- # self.tweet_map[t['id']] = t
- if 'data' not in data or not data['data']:
- return False
- l = data['data'] if type(data['data']) == list else [data['data']]
- for t in l:
- if 'entities' in t:
- self.add_entity_urls(t['entities'])
- post = self.make_post(t)
- self.pages[self.pagination_page].append(post)
- return True
-
- def add_user_ids_from_search(self, data):
- if not data['data']:
- return False
- for u in data['data']:
- self.pages[self.pagination_page].append(User(
- 'twitter:u:{}'.format(u['id']), username=u['username'], display_name=u['name']))
- return True
-
- def should_process_index(self, index):
- if index - self.pagination_page >= REACH_LIMIT:
- return False
- if self.pagination_page > 0 and not self.pagination_token:
- self.completed = True
- return False
- if self.pagination_token:
- self.params['pagination_token'] = self.pagination_token
- self.pages[self.pagination_page] = []
- return True
-
- def handle_data(self, data, user_ids=False):
- if 'next_token' not in data['meta'].keys():
- self.pagination_token = None
- else:
- self.pagination_token = data['meta']['next_token']
- if user_ids:
- if not self.add_user_ids_from_search(data):
- return False
- else:
- if not self.add_items_from_search(data):
- return False
- self.pagination_page += 1
- return True
-
- def get_user_id(self, username):
- if self.user_id:
- return True
- try:
- data = self.provider.t.users.by.username._username(
- _username=username, _timeout=config.TWITTER_TIMEOUT)
- except TwitterError as e:
- log.error(repr(e))
- return False
- self.user_id = data['data']['id']
- return True
-
-class TwitterSearch(TwitterBase):
- def load_page(self, index):
- for _ in range(self.pagination_page, index + 1):
- if not self.should_process_index(index):
- return False
- try:
- data = self.provider.t.tweets.search.recent(
- query=self.arg, expansions=self.SOME_EXPRESSIONS, params=self.params,
- sort_order='relevancy', max_results=25, _timeout=config.TWITTER_TIMEOUT)
- except TwitterError as e:
- log.error(repr(e))
- return False
- return self.handle_data(data)
-
-class TwitterUser(TwitterBase):
- def __init__(self, userdata, arg):
- if arg.startswith('@'):
- arg = arg[1:]
- super().__init__(userdata, arg)
-
- def load_page(self, index):
- for _ in range(self.pagination_page, index + 1):
- if not self.should_process_index(index):
- return False
- if not self.get_user_id(self.arg):
- return False
- try:
- data = self.provider.t.users._id.tweets(
- _id=self.user_id, expansions=self.SOME_EXPRESSIONS, params=self.params,
- max_results=25, _timeout=config.TWITTER_TIMEOUT)
- except TwitterError as e:
- log.error(repr(e))
- return False
- return self.handle_data(data)
-
-class TwitterTimeline(TwitterBase):
- def __init__(self, userdata, arg):
- if not arg:
- arg = 'pizzabelly'
- super().__init__(userdata, arg)
- self.params['exclude'] = 'replies'
-
- def load_page(self, index):
- for _ in range(self.pagination_page, index + 1):
- if not self.should_process_index(index):
- return False
- if not self.get_user_id(self.arg):
- return False
- try:
- data = self.provider.t.users._id.timelines.reverse_chronological(
- _id=self.user_id, expansions=self.SOME_EXPRESSIONS, params=self.params,
- max_results=25, _timeout=config.TWITTER_TIMEOUT)
- except TwitterError as e:
- log.error(repr(e))
- return False
- return self.handle_data(data)
-
-class TwitterLikes(TwitterBase):
- def __init__(self, userdata, arg):
- if arg.startswith('@'):
- arg = arg[1:]
- super().__init__(userdata, arg)
-
- def load_page(self, index):
- for _ in range(self.pagination_page, index + 1):
- if not self.should_process_index(index):
- return False
- if not self.get_user_id(self.arg):
- return False
- try:
- data = self.provider.t.users._id.liked_tweets(
- _id=self.user_id, expansions=self.SOME_EXPRESSIONS, params=self.params,
- max_results=25, _timeout=config.TWITTER_TIMEOUT)
- except TwitterError as e:
- log.error(repr(e))
- return False
- return self.handle_data(data)
-
-class TwitterTweet(TwitterBase):
- def load_page(self, index):
- for _ in range(self.pagination_page, index + 1):
- if not self.should_process_index(index):
- return False
- try:
- data = self.provider.t.tweets(
- ids=self.arg, expansions=self.SOME_EXPRESSIONS, params=self.params,
- _timeout=config.TWITTER_TIMEOUT)
- except TwitterError as e:
- log.error(repr(e))
- return False
- return self.handle_data(data)
-
-class TwitterFollowing(TwitterBase):
- def __init__(self, userdata, arg):
- if arg.startswith('@'):
- arg = arg[1:]
- super().__init__(userdata, arg)
- del self.params['media.fields']
- del self.params['place.fields']
- del self.params['poll.fields']
-
- def load_page(self, index):
- for _ in range(self.pagination_page, index + 1):
- if not self.should_process_index(index):
- return False
- if not self.get_user_id(self.arg):
- return False
- try:
- data = self.provider.t.users._id.following(
- _id=self.user_id, params=self.params, max_results=25, _timeout=config.TWITTER_TIMEOUT)
- except TwitterError as e:
- log.error(repr(e))
- return False
- return self.handle_data(data, True)
-'''
-
-class TwitterApiModule(Module):
- def __init__(self, access_key: str, access_secret: str, consumer_key: str, consumer_secret: str):
- self.t: Twitter2 = Twitter2(auth=OAuth(access_key, access_secret, consumer_key, consumer_secret), retry=True)
- self.parser: QueryParser = QueryParser(self, 'timeline')
- #self.parser.add_command('timeline', TwitterTimeline)
- #self.parser.add_command('likes', TwitterLikes)
- #self.parser.add_command('following', TwitterFollowing)
diff --git a/src/portal/py/modules/twitter_common.py b/src/portal/py/modules/twitter_common.py
new file mode 100644
index 0000000..5d1587d
--- /dev/null
+++ b/src/portal/py/modules/twitter_common.py
@@ -0,0 +1,177 @@
+import sys
+import json
+import email.utils
+from json import JSONDecodeError
+from typing import Optional, Any
+from base import USER_AGENT, Search, Module, Method, ParsedJson
+from post import Url, PostType, PostRef, Date, DateType, DateMeta, Post, User, Media, Image, Video, PostEncoder
+from modules.common import get_current_utc_time
+
+class TwitterParserState():
+ def __init__(self, response_hash: str, mtime: Optional[float], module: Module):
+ self.response_hash = response_hash;
+ self.mtime: float = mtime if mtime else get_current_utc_time().timestamp()
+ self.module: Module = module
+ self.cursor: Optional[dict] = None
+ self.page: list[str] = []
+
+def parse_media_url_https(url: str) -> (Url, Url):
+ question = url.rfind('?')
+ if question >= 0:
+ url = url[0:question]
+ ext = url.rsplit('.')[-1]
+ return Url(f'{url}?format={ext}&name=orig', ext), Url(f'{url}?format={ext}&name=small', ext)
+
+def create_media_graphql(m: ParsedJson) -> Media:
+ original, thumbnail = parse_media_url_https(m['media_url_https'])
+ if m['type'] == 'photo':
+ return Image(url=original, thumbnail_url=thumbnail)
+ elif m['type'] == 'video' or m['type'] == 'animated_gif':
+ variants = sorted(m['video_info']['variants'], key=lambda x: x['bitrate'] if 'bitrate' in x else 0, reverse=True)
+ return Video(url=Url(variants[0]['url']), thumbnail_url=thumbnail)
+ return None
+
+def create_post_graphql(data: ParsedJson, tps: TwitterParserState) -> str:
+ kwargs = {}
+ kwargs['module'] = 'twitter'
+ tweet_id = data['rest_id']
+ unique_id = f'twitter:t:{tweet_id}'
+ kwargs['unique_id'] = unique_id
+ kwargs['raw_responses'] = {}
+ kwargs['raw_responses']['graphql'] = tps.response_hash
+ kwargs['dates'] = [Date(DateType.RETRIEVED, (tps.mtime, tps.mtime), DateMeta.NONE)]
+ if 'core' in data and 'user_results' in data['core']:
+ user_data = data['core']['user_results']['result']
+ if user_data['__typename'] != 'User':
+ print(f'Unexpected type {user_data['__typename']} when expecting User')
+ user_id = user_data['rest_id']
+ user = User(unique_id=f'twitter:u:{user_id}')
+ if 'core' in user_data:
+ user.username = user_data['core']['screen_name']
+ user.name = user_data['core']['name']
+ else:
+ user.username = user_data['legacy']['screen_name']
+ user.name = user_data['legacy']['name']
+ if 'avatar' in user_data:
+ user.profile_picture_url = Url(user_data['avatar']['image_url'])
+ else:
+ user.profile_picture_url = Url(user_data['legacy']['profile_image_url_https'])
+ kwargs['url'] = f'https://twitter.com/{user.username}/status/{tweet_id}'
+ kwargs['author'] = user
+ if 'legacy' not in data or ('__typename' in data and (data['__typename'] == 'TweetTombstone' or data['__typename'] == 'TweetUnavailable')):
+ kwargs['type'] = PostType.TOMBSTONE
+ post = Post(**kwargs)
+ tps.module.add_to_map(unique_id, post)
+ return unique_id
+ legacy = data['legacy']
+ create_date = email.utils.parsedate_to_datetime(legacy['created_at']).timestamp()
+ kwargs['dates'].append(Date(DateType.CREATED, (create_date, create_date), DateMeta.NONE))
+ # @TODO: Find example of retweeted_status_id.
+ is_retweet = 'retweeted_status_result' in legacy or 'retweeted_status_id' in legacy
+ if is_retweet:
+ kwargs['type'] = PostType.REPOST
+ if 'retweeted_status_result' in legacy and 'result' in legacy['retweeted_status_result']:
+ retweet_id = PostRef(create_post_graphql(legacy['retweeted_status_result']['result'], tps))
+ elif 'retweeted_status_id' in legacy:
+ retweet_id = PostRef(f'twitter:t:{legacy['retweeted_status_id']}')
+ else:
+ print('Unknown ID for supposed retweet.')
+ retweet_id = PostRef()
+ post = Post(**kwargs)
+ tps.module.add_to_map(unique_id, post)
+ return unique_id
+ kwargs['type'] = PostType.POST
+ kwargs['text'] = legacy.get('full_text', '')
+ kwargs['likes'] = legacy.get('favorite_count', None)
+ kwargs['bookmarks'] = legacy.get('bookmark_count', None)
+ kwargs['reposts'] = legacy.get('retweet_count', None)
+ kwargs['quotes'] = legacy.get('quote_count', None)
+ kwargs['comments'] = legacy.get('reply_count', None)
+ if 'views' in data:
+ kwargs['views'] = data['views'].get('count', None)
+ merged_entities = []
+ if 'extended_entities' in legacy and 'media' in legacy['extended_entities']:
+ merged_entities.extend(legacy['extended_entities']['media'])
+ merged_entities.extend(legacy['entities'].get('media', []))
+ seen_media_ids = []
+ kwargs['media'] = {}
+ for m in merged_entities:
+ if m['id_str'] in seen_media_ids:
+ continue
+ seen_media_ids.append(m['id_str'])
+ kwargs['media'][m.get('media_key', m['id_str'])] = create_media_graphql(m)
+ # quotedRefResult references the tweet that quoted this tweet, not the tweet this tweet is quoting.
+ has_quoted = 'quoted_status_result' in legacy or 'quoted_status_id_str' in legacy
+ if has_quoted:
+ if 'quoted_status_result' in legacy and 'result' in legacy['quoted_status_result']:
+ quoted_id = PostRef(create_post_graphql(legacy['quoted_status_result']['result'], tps))
+ elif 'quoted_status_id_str' in legacy:
+ quoted_id = PostRef(f'twitter:t:{legacy['quoted_status_id_str']}')
+ else:
+ print('Unknown ID for supposed quoted tweet.')
+ quoted_id = PostRef()
+ if 'in_reply_to_status_id_str' in legacy:
+ kwargs['in_reply_to'] = PostRef(f'twitter:t:{legacy['in_reply_to_status_id_str']}')
+ post = Post(**kwargs)
+ tps.module.add_to_map(unique_id, post)
+ return unique_id
+
+def parse_graphql_tweet(tweet: ParsedJson, tps: TwitterParserState):
+ entry_id = tweet['entryId']
+ if 'item' not in tweet or 'itemContent' not in tweet['item']:
+ print(f'Unhandled Timeline entry {entry_id}')
+ return
+ content = tweet['item']['itemContent']
+ if content['__typename'] != 'TimelineTweet':
+ print(f'Unhandled Timeline content type {content['__typename']}')
+ return
+ result = content['tweet_results']['result']
+ if result['__typename'] != 'Tweet':
+ print(f'Unhandled Tweet type {result['__typename']}')
+ return
+ tps.page.append(create_post_graphql(result, tps))
+
+def parse_graphql_timeline_v2(timeline: ParsedJson, tps: TwitterParserState):
+ for ins in timeline['timeline']['instructions']:
+ ins_type = ins['type']
+ nops = ['TimelineClearCache', 'TimelineTerminateTimeline', 'TimelinePinEntry']
+ if ins_type in nops:
+ print(f'no-op: {ins_type}')
+ elif ins_type == 'TimelineAddEntries':
+ for entry in ins['entries']:
+ entry_id = entry['entryId']
+ if entry_id.startswith('cursor-top-'):
+ pass
+ elif entry_id.startswith('cursor-bottom-'):
+ tps.cursor = entry['content']['value']
+ elif 'content' in entry and entry['content']['entryType'] == 'TimelineTimelineModule':
+ for item in entry['content']['items']:
+ parse_graphql_tweet(item, tps)
+ elif ins_type == 'TimelineAddToModule':
+ for item in ins['moduleItems']:
+ parse_graphql_tweet(item, tps)
+ else:
+ print(f'Unknown instruction: {ins_type}')
+
+def parse_graphql_raw_response(obj: ParsedJson, response_hash: str, mtime: Optional[float], module: Module) -> TwitterParserState:
+ tps = TwitterParserState(response_hash, mtime, module)
+ if 'data' in obj:
+ if 'user' in obj['data']:
+ user = obj['data']['user']
+ if 'result' in user:
+ parse_graphql_timeline_v2(
+ user['result']['timeline_v2'] if 'timeline_v2' in user['result'] else user['result']['timeline'], tps)
+ if len(tps.page) == 0:
+ print(f'Response with 0 tweets:\n{json.dumps(obj)}')
+ else:
+ print('Invalid graphql user.')
+ else:
+ print(f'Unexpected response:\n{json.dumps(obj)}')
+ return tps
+
+def parse_v1_raw_response(obj: ParsedJson, reponse_hash: str, mtime: Optional[float]):
+ pass
+
+#target = sys.argv[1]
+#with open(target, 'r') as f:
+# parse_graphql_raw_response(json.loads(f.read())[0], 'AAAAAAAA', None, Module())
diff --git a/src/portal/py/modules/twitter_scrape.py b/src/portal/py/modules/twitter_scrape.py
new file mode 100644
index 0000000..93286c3
--- /dev/null
+++ b/src/portal/py/modules/twitter_scrape.py
@@ -0,0 +1,191 @@
+import log
+import http.cookiejar
+from typing import Optional, Any, Iterator
+from datetime import timezone
+from base import USER_AGENT, Search, Module
+from post import Url, PostType, Date, DateType, DateMeta, PostRef, Post, User, Media, Image, Video
+from query_parser import QueryParser
+from modules.common import get_current_utc_time
+
+# GraphQL API
+from snscrape.base import ScraperException
+from snscrape.modules import twitter
+from snscrape.modules.twitter import (Tweet, TweetRef, Tombstone, UserRef,
+ TwitterSearchScraper, TwitterSearchScraperMode, TwitterProfileScraper, TwitterUserScraper, TwitterTweetScraper)
+
+class TwitterScrapeBase(Search):
+ # Posts per emulated page because snscrape returns an iterator.
+ POSTS_PER_PAGE = 8
+
+ # Don't allow requests for a page more than this amount past the current page.
+ REACH_LIMIT = 4
+
+ def __init__(self, userdata: Any) -> None:
+ super().__init__()
+ self.module: TwitterScrapeModule = userdata
+ self.iterator: Iterator[Tweet | TweetRef | Tombstone]
+ self.page: int = 0
+
+ def create_media_url(self, url: str) -> Url:
+ format = url.find('format=')
+ ext = None
+ if format >= 0:
+ ext = url[format + 7:]
+ amp = ext.find('&')
+ if amp >= 0:
+ ext = ext[:amp]
+ return Url(url, ext)
+
+ def parse_media(self, data: list[twitter.Medium]) -> dict[str, Media]:
+ media = {}
+ for index, m in enumerate(data):
+ if isinstance(m, twitter.Photo):
+ media[str(index)] = Image(url=self.create_media_url(m.fullUrl), thumbnail_url=self.create_media_url(m.previewUrl))
+ elif isinstance(m, twitter.Video) or isinstance(m, twitter.Gif):
+ videos = sorted(m.variants, key=lambda x: x.bitrate if x.bitrate else 0, reverse=True)
+ media[str(index)] = Video(url=self.create_media_url(videos[0].url), thumbnail_url=self.create_media_url(m.thumbnailUrl))
+ return media
+
+ def create_post(self, tweet: Tweet | TweetRef | Tombstone) -> Post:
+ kwargs = {}
+ kwargs['module'] = 'twitter_scrape'
+ kwargs['unique_id'] = f'twitter:t:{tweet.id}'
+ if isinstance(tweet, TweetRef) or isinstance(tweet, Tombstone):
+ kwargs['type'] = PostType.TOMBSTONE
+ return Post(**kwargs)
+ kwargs['raw_responses'] = tweet.rawResponses
+ kwargs['url'] = tweet.url
+ create_date = tweet.date.replace(tzinfo=timezone.utc).timestamp()
+ current_date = get_current_utc_time().timestamp()
+ kwargs['dates'] = [
+ Date(DateType.CREATED, (create_date, create_date), DateMeta.NONE),
+ Date(DateType.RETRIEVED, (current_date, current_date), DateMeta.NONE)
+ ]
+ author = User(unique_id=f'twitter:u:{tweet.user.id}')
+ if not isinstance(tweet.user, UserRef):
+ author.username = tweet.user.username
+ if tweet.user.displayname:
+ author.display_name = tweet.user.displayname
+ if tweet.user.profileImageUrl:
+ author.profile_picture_url = self.create_media_url(tweet.user.profileImageUrl)
+ kwargs['author'] = author
+ if tweet.retweetedTweet:
+ kwargs['type'] = PostType.REPOST
+ kwargs['post'] = PostRef(f'twitter:t:{tweet.retweetedTweet.id}')
+ return Post(**kwargs)
+ kwargs['type'] = PostType.POST
+ kwargs['text'] = tweet.rawContent
+ kwargs['likes'] = tweet.likeCount
+ kwargs['reposts'] = tweet.retweetCount
+ kwargs['quotes'] = tweet.quoteCount
+ kwargs['comments'] = tweet.replyCount
+ kwargs['views'] = tweet.viewCount
+ if tweet.media:
+ kwargs['media'] = self.parse_media(tweet.media)
+ if tweet.inReplyToTweetId:
+ kwargs['in_reply_to'] = PostRef(f'twitter:t:{tweet.inReplyToTweetId}')
+ if tweet.quotedTweet:
+ kwargs['quoted'] = PostRef(f'twitter:t:{tweet.quotedTweet.id}')
+ return Post(**kwargs)
+
+ def add_post_to_page(self, num: int, item: Post | User) -> None:
+ self.module.add_to_map(item.unique_id, item)
+ self.pages[num].append(item.unique_id)
+
+ def step_iterator_for_page(self, num: int) -> bool:
+ for _ in range(0, self.POSTS_PER_PAGE):
+ try:
+ tweet = next(self.iterator)
+ except StopIteration:
+ self.completed = True
+ break
+ except ScraperException as e:
+ log.error(repr(e))
+ continue
+ self.add_post_to_page(num, self.create_post(tweet))
+ if isinstance(tweet, TweetRef) or isinstance(tweet, Tombstone):
+ continue
+ if tweet.retweetedTweet:
+ retweet = self.create_post(tweet.retweetedTweet)
+ self.module.add_to_map(retweet.unique_id, retweet)
+ if tweet.quotedTweet:
+ quote = self.create_post(tweet.quotedTweet)
+ self.module.add_to_map(quote.unique_id, quote)
+ return len(self.pages[num]) > 0
+
+ def request_page(self, num: int) -> bool:
+ if num - self.page >= self.REACH_LIMIT:
+ return False
+ request_satisfied = False
+ for index in range(self.page, num + 1):
+ if self.completed:
+ break
+ if index in self.pages:
+ continue
+ self.pages[index] = []
+ if not self.step_iterator_for_page(index):
+ break
+ self.page = index
+ if index == num:
+ request_satisfied = True
+ return request_satisfied
+
+class TwitterScrapeSearch(TwitterScrapeBase):
+ def __init__(self, userdata: Any, arg: str) -> None:
+ super().__init__(userdata)
+ self.iterator = TwitterSearchScraper(arg, mode=TwitterSearchScraperMode.TOP, cookies=self.module.cookies).get_items()
+
+class TwitterScrapeUser(TwitterScrapeBase):
+ def __init__(self, userdata: Any, arg: str) -> None:
+ super().__init__(userdata)
+ if arg.startswith('@'):
+ arg = arg[1:]
+ self.iterator = TwitterUserScraper(arg, cookies=self.module.cookies).get_items()
+
+class TwitterScrapeProfile(TwitterScrapeBase):
+ def __init__(self, userdata: Any, arg: str) -> None:
+ super().__init__(userdata)
+ if arg.startswith('@'):
+ arg = arg[1:]
+ self.iterator = TwitterProfileScraper(arg, cookies=self.module.cookies).get_items()
+
+class TwitterScrapeTweet(TwitterScrapeBase):
+ def __init__(self, userdata: Any, arg: str) -> None:
+ super().__init__(userdata)
+ if arg.startswith('https://'):
+ arg = arg[arg.rfind('/') + 1:]
+ question = arg.find('?')
+ if question >= 0:
+ arg = arg[:question]
+ self.iterator = TwitterTweetScraper(arg, cookies=self.module.cookies).get_items()
+
+class TwitterScrapeModule(Module):
+ def __init__(self, cookies_path: str) -> None:
+ super().__init__()
+ self.parser: QueryParser = QueryParser(self, 'search')
+ self.parser.add_command('search', TwitterScrapeSearch)
+ self.parser.add_command('user', TwitterScrapeUser)
+ self.parser.add_command('profile', TwitterScrapeProfile)
+ self.parser.add_command('tweet', TwitterScrapeTweet)
+ if cookies_path:
+ cookie_jar = http.cookiejar.MozillaCookieJar()
+ cookie_jar.load(filename=cookies_path, ignore_expires=True)
+ for c in cookie_jar:
+ if c.value:
+ self.cookies[c.name] = c.value
+
+ def search(self, query: str, *extra_args: Any) -> Optional[Search]:
+ return self.parser.parse_query(query)
+
+ def get_download(self, unique_id: str, key: str) -> Optional[dict[str, Any]]:
+ if unique_id not in self.unique_id_map:
+ return None
+ post = self.unique_id_map[unique_id]
+ if key not in post.media:
+ return None
+ return {
+ 'urls': [post.media[key].url],
+ 'headers': {
+ 'User-Agent': USER_AGENT
+ }
+ }
diff --git a/src/portal/py/modules/youtube.py b/src/portal/py/modules/youtube.py
index 9d5a135..77e17b3 100644
--- a/src/portal/py/modules/youtube.py
+++ b/src/portal/py/modules/youtube.py
@@ -5,6 +5,7 @@ from base import Search, Module, ParsedJson
from post import Url, Post, PostType, Media, Video
from query_parser import QueryParser
from yt_dlp import YoutubeDL
+from yt_dlp.cookies import extract_cookies_from_browser
class YDLLogger():
def debug(self, message: str) -> None:
@@ -31,6 +32,8 @@ ydl_opts = {
# 'cookiefile': ''
}
+#print(extract_cookies_from_browser('firefox'))
+
# https://github.com/yt-dlp/yt-dlp/issues/4103
ydl = YoutubeDL(ydl_opts)
diff --git a/src/portal/py/tests/archive_query.py b/src/portal/py/tests/archive_query.py
index 3de4329..8ecbb52 100644
--- a/src/portal/py/tests/archive_query.py
+++ b/src/portal/py/tests/archive_query.py
@@ -1,20 +1,35 @@
import os
import sys
import signal
-import json
import threading
+import json
import gzip
from datetime import datetime, timezone
sys.path.append('../')
from base import Method
from post import PostEncoder, PostType, DateType, User
from modules import ALL_MODULES
-from tests.logger import Logger
+
+class Logger():
+ def __init__(self, path):
+ self.file = open(path, 'a+')
+ self.mutex = threading.Lock()
+
+ def write(self, message):
+ with self.mutex:
+ print(message)
+ self.file.write(message + '\n')
+ self.file.flush()
+
+ def close(self):
+ self.file.close()
mode = 'pixiv_web'
#mode = 'fanbox'
#mode = 'patreon'
#mode = 'instagram'
+#mode = 'twitter'
+#cmd = 'profile'
cmd = 'user'
#cmd = 'bookmarks'
#cmd = 'search'
@@ -151,8 +166,8 @@ class QueryDownloadThread(threading.Thread):
while completed:
self.log.write(f'Starting page {num} of {arg}.')
page = search.get_page(num)
- for hash, response in module.raw_responses.items():
- raw_response_path = f'{self.output_dir}/raw_responses/{hash}.json.gz'
+ for hash_str, response in module.raw_responses.items():
+ raw_response_path = f'{self.output_dir}/raw_responses/{hash_str}.json.gz'
if not os.path.isfile(raw_response_path):
if not self.write_to_file(raw_response_path, 'wb+', response, True):
completed = False
@@ -161,8 +176,8 @@ class QueryDownloadThread(threading.Thread):
break
num += 1
for unique_id in page:
- if unique_id == 'twitter:t:1597637140833529856':
- self.log.write('---------Hit target---------')
+ #if unique_id == '':
+ # self.log.write('---------Hit target---------')
post = module.get_item(unique_id)
if not post:
self.log.write(f'Missing post with id {unique_id}.')
@@ -182,8 +197,8 @@ class QueryDownloadThread(threading.Thread):
#RUNTIME_PATH = './run'
RUNTIME_PATH = '/mnt/store/files/tmp/run'
-LOG_FILE = f'{RUNTIME_PATH}/archive4.log'
-ARG_FILE = f'{RUNTIME_PATH}/completed_args4.log'
+LOG_FILE = f'{RUNTIME_PATH}/archive5.log'
+ARG_FILE = f'{RUNTIME_PATH}/completed_args5.log'
def read_completed_args(path):
args = []
@@ -214,6 +229,7 @@ def download_args():
output_dir = sys.argv[1]
if not os.path.isdir(output_dir):
os.mkdir(output_dir)
+ if not os.path.isdir(f'{output_dir}/raw_responses'):
os.mkdir(f'{output_dir}/raw_responses')
args = []
diff --git a/src/portal/py/tests/logger.py b/src/portal/py/tests/logger.py
deleted file mode 100644
index 3adf792..0000000
--- a/src/portal/py/tests/logger.py
+++ /dev/null
@@ -1,15 +0,0 @@
-import threading
-
-class Logger():
- def __init__(self, path):
- self.file = open(path, 'a+')
- self.mutex = threading.Lock()
-
- def write(self, message):
- with self.mutex:
- print(message)
- self.file.write(message + '\n')
- self.file.flush()
-
- def close(self):
- self.file.close()
diff --git a/src/portal/py/tests/test.py b/src/portal/py/tests/test.py
deleted file mode 100644
index d3e5640..0000000
--- a/src/portal/py/tests/test.py
+++ /dev/null
@@ -1,8 +0,0 @@
-#from twitter.scraper import Scraper
-#scraper = Scraper(cookies = {
-# 'ct0': '',
-# 'auth_token': ''
-#})
-#
-#media = scraper.media([1557919548904419328], limit=10)
-#print(media)
diff --git a/src/portal/py/tests/touch_all.py b/src/portal/py/tests/touch_all.py
index 6d6bfa1..158c7dd 100644
--- a/src/portal/py/tests/touch_all.py
+++ b/src/portal/py/tests/touch_all.py
@@ -3,36 +3,24 @@ from typing import Optional, Any
from post import PostEncoder
from modules import ALL_MODULES
-'''
-post = module.get_item(page[0])
-for key in post.media.keys():
- download_params = module.get_download(post.unique_id, key)
- for url in download_params['urls']:
- #response = module.do_request(Method.GET, url.url)
- r = httpx.get(url.url, headers=download_params['headers'])
- if r.status_code != 200:
- print('error{}'.format(r.status_code))
- else:
- with open(f'test_{key}.{url.ext}', 'wb+') as f:
- f.write(r.content)
-'''
-
def print_page(module: Any, page: Optional[list[str]]) -> None:
if not page:
print('Page is None')
return
- count = 0
for unique_id in page:
post = module.get_item(unique_id)
print(json.dumps(post, indent=4, cls=PostEncoder))
- count = count + 1
- if count == 2:
- break
-''' Blocked on certain endpoints, snscrape is outdated.
module = ALL_MODULES['twitter'][0]
module.init()
+search = module.search('user:de7_7_7e')
+print_page(module, search.get_page(0))
+
+''' Blocked on most endpoints, snscrape is outdated.
+module = ALL_MODULES['twitter_scrape'][0]
+module.init()
+
search = module.search('search:#小関麗奈誕生祭2024')
print_page(module, search.get_page(0))
search = module.search('user:xuuikie')
@@ -77,3 +65,17 @@ print_page(module, search.get_page(0))
search = module.search('illust:100455669') # Deleted.
print_page(module, search.get_page(0))
'''
+
+'''
+post = module.get_item(page[0])
+for key in post.media.keys():
+ download_params = module.get_download(post.unique_id, key)
+ for url in download_params['urls']:
+ #response = module.do_request(Method.GET, url.url)
+ r = httpx.get(url.url, headers=download_params['headers'])
+ if r.status_code != 200:
+ print('error{}'.format(r.status_code))
+ else:
+ with open(f'test_{key}.{url.ext}', 'wb+') as f:
+ f.write(r.content)
+'''
diff --git a/src/portal/requirements.txt b/src/portal/requirements.txt
index 0d31ac1..c2e2915 100644
--- a/src/portal/requirements.txt
+++ b/src/portal/requirements.txt
@@ -1,2 +1,3 @@
httpx[http2,brotli,zstd]
blake3
+pillow
diff --git a/src/render/queue_libplacebo.c b/src/render/queue_libplacebo.c
index 488c3b5..1864607 100644
--- a/src/render/queue_libplacebo.c
+++ b/src/render/queue_libplacebo.c
@@ -62,7 +62,7 @@ static bool map_frame(pl_gpu gpu, pl_tex *tex, const struct pl_source_frame *src
masks[3] = 0xff000000;
pixel_stride = 4;
break;
- case CAMU_PIXEL_FORMAT_RGB:
+ case CAMU_PIXEL_FORMAT_RGB8:
masks[0] = 0x000000ff;
masks[1] = 0x0000ff00;
masks[2] = 0x00ff0000;
diff --git a/src/render/renderer_libplacebo.c b/src/render/renderer_libplacebo.c
index 191c40d..c5f4de8 100644
--- a/src/render/renderer_libplacebo.c
+++ b/src/render/renderer_libplacebo.c
@@ -20,6 +20,12 @@
#include "renderer_libplacebo.h"
#include "queue_libplacebo.h"
+#ifdef AL_DEBUG
+#define RENDERER_DEBUG 1
+#else
+#define RENDERER_DEBUG 0
+#endif
+
static f32 clear_color[4] = { 0.f, 0.f, 0.f, 1.f };
static inline void pl_swapchain_resize_compat(pl_swapchain swapchain, u32 *width, u32 *height)
@@ -158,10 +164,10 @@ static bool renderer_lp_create_renderer(struct camu_renderer *renderer, u32 *wid
u32 num;
const char *const *extensions = vk_get_extensions(&num);
lr->vk_inst = pl_vk_inst_create(lr->logger, pl_vk_inst_params(
+ .debug = RENDERER_DEBUG,
.get_proc_addr = get_vk_proc_address,
- .debug = false,
.extensions = extensions,
- .num_extensions = num,
+ .num_extensions = num
));
if (!lr->vk_inst) {
log_error("Failed to create vulkan instance.");
@@ -175,7 +181,7 @@ static bool renderer_lp_create_renderer(struct camu_renderer *renderer, u32 *wid
}
lr->vk = pl_vulkan_create(lr->logger, pl_vulkan_params(
- .allow_software = true,
+ .allow_software = RENDERER_DEBUG,
.instance = lr->vk_inst->instance,
.get_proc_addr = lr->vk_inst->get_proc_addr,
.surface = lr->surface
@@ -201,7 +207,7 @@ static bool renderer_lp_create_renderer(struct camu_renderer *renderer, u32 *wid
#elif defined STELA_API_DX11
(void)priv;
lr->d3d11 = pl_d3d11_create(lr->logger, pl_d3d11_params(
- .debug = false
+ .debug = RENDERER_DEBUG
));
if (!lr->d3d11) {
log_error("Failed to create D3D11 device.");
@@ -222,8 +228,8 @@ static bool renderer_lp_create_renderer(struct camu_renderer *renderer, u32 *wid
#elif defined STELA_API_OPENGL
(void)gl_load_loader;
lr->gl = pl_opengl_create(lr->logger, pl_opengl_params(
- .debug = false,
- .allow_software = true,
+ .debug = RENDERER_DEBUG,
+ .allow_software = RENDERER_DEBUG,
.make_current = gl_make_current,
.release_current = gl_release_current,
.get_proc_addr = get_gl_proc_address,
@@ -232,7 +238,7 @@ static bool renderer_lp_create_renderer(struct camu_renderer *renderer, u32 *wid
.egl_display = display,
.egl_context = context,
#endif
- .priv = priv,
+ .priv = priv
));
if (!lr->gl) {
log_error("Failed to create GL device.");
@@ -260,6 +266,7 @@ static bool renderer_lp_create_renderer(struct camu_renderer *renderer, u32 *wid
lr->have_frame = false;
al_memset(&lr->params, 0, sizeof(struct pl_render_params));
+ //lr->params = pl_render_fast_params;
lr->params = pl_render_default_params;
lr->params.deband_params = NULL;
//lr->params.frame_mixer = NULL;
diff --git a/src/screen/screen.c b/src/screen/screen.c
index 965088f..bed1a26 100644
--- a/src/screen/screen.c
+++ b/src/screen/screen.c
@@ -1,7 +1,7 @@
#define AL_LOG_SECTION "screen"
#include <al/log.h>
+#include <al/math.h>
#include <nnwt/thread.h>
-#include <math.h>
#include "view.h"
#include "screen.h"
@@ -12,6 +12,7 @@
#define SCREEN_IS_ZOOMING(scr) ((scr)->flags & CAMU_SCREEN_ZOOMING)
#define SCREEN_ZOOM_MODE(scr, mode) ((scr)->flags & mode)
#define SCREEN_INVALID_TS ((u64)-1)
+#define SCREEN_DEFAULT_CURSOR(scr) ((scr->fullscreen || scr->vr_emulation) ? STELA_CURSOR_HIDDEN : STELA_CURSOR_NORMAL)
#define SCREEN_LAST_CLICK_WITHIN(ns) \
(((scr)->last_click_ts != SCREEN_INVALID_TS) && (nn_get_timestamp() - (scr)->last_click_ts <= ns))
@@ -172,7 +173,7 @@ static bool mouse_button_callback(void *userdata, u8 state, u8 button)
switch (state) {
case STELA_BUTTON_PRESSED:
scr->flags |= CAMU_SCREEN_DRAGGING;
- scr->window->set_cursor(scr->window, STELA_CURSOR_CROSSHAIR);
+ scr->window->set_cursor(scr->window, STELA_CURSOR_MOVE);
#ifdef CAMU_SCREEN_DRAG_SEEK
//scr->callback(scr->userdata, CAMU_SCREEN_TOGGLE_PAUSE, NULL);
#endif
@@ -180,8 +181,7 @@ static bool mouse_button_callback(void *userdata, u8 state, u8 button)
break;
case STELA_BUTTON_RELEASED:
scr->flags &= ~CAMU_SCREEN_DRAGGING;
- scr->window->set_cursor(scr->window, (scr->fullscreen || scr->vr_emulation) ?
- STELA_CURSOR_HIDDEN : STELA_CURSOR_NORMAL);
+ scr->window->set_cursor(scr->window, SCREEN_DEFAULT_CURSOR(scr));
#ifdef CAMU_SCREEN_DRAG_SEEK
//scr->callback(scr->userdata, CAMU_SCREEN_TOGGLE_PAUSE, NULL);
#endif
@@ -196,13 +196,25 @@ static bool mouse_button_callback(void *userdata, u8 state, u8 button)
break;
}
break;
+ case STELA_MOUSE2: {
+ if (scr->fullscreen && !SCREEN_IS_DRAGGING(scr)) {
+ switch (state) {
+ case STELA_BUTTON_PRESSED:
+ scr->window->set_cursor(scr->window, STELA_CURSOR_CROSSHAIR);
+ break;
+ case STELA_BUTTON_RELEASED:
+ scr->window->set_cursor(scr->window, SCREEN_DEFAULT_CURSOR(scr));
+ break;
+ }
+ }
+ break;
+ }
case STELA_MOUSE3: {
switch (state) {
- case STELA_BUTTON_RELEASED: {
+ case STELA_BUTTON_RELEASED:
seek_to_percent_at_pointer(scr, scr->last_pointer_x);
break;
}
- }
break;
}
default:
@@ -215,26 +227,18 @@ bool scroll_callback(void *userdata, f64 y)
{
struct camu_screen *scr = (struct camu_screen *)userdata;
if (SCREEN_MOD1(scr)) {
- f32 change = (y < 0.0) ? 0.025 : -0.025;
+ f32 change = (f32)stl_scale_scroll(y, 40.0);
scr->callback(scr->userdata, CAMU_SCREEN_OFFSET_VOLUME, &change);
} else {
- // This sucks.
-#if defined STELA_WINDOW_WAYLAND
- y = -y / 160.0;
-#elif defined STELA_WINDOW_WIN32
- y = -y / 1600.0;
-#else
- y = -y / 20.0;
-#endif
struct camu_view *view = get_view_from_mouse_pos(scr);
if (view) {
if (SCREEN_ZOOM_MODE(scr, CAMU_SCREEN_ZOOM_PAN_SIMPLE)) {
- if (camu_view_zoom_simple(view, scr->width, scr->height, scr->last_pointer_x, scr->last_pointer_y, y)) {
+ if (camu_view_zoom_simple(view, scr->width, scr->height, scr->last_pointer_x, scr->last_pointer_y, stl_scale_scroll(y, 15.0))) {
view->mode = CAMU_VIEW_DETACHED;
return true;
}
} else if (SCREEN_ZOOM_MODE(scr, CAMU_SCREEN_ZOOM_FOV)) {
- view->fov -= y;
+ camu_view_zoom_fov(view, stl_scale_scroll(y, 20.0));
}
}
}
diff --git a/src/screen/view.c b/src/screen/view.c
index 1676084..4719062 100644
--- a/src/screen/view.c
+++ b/src/screen/view.c
@@ -1,5 +1,5 @@
#include <al/lib.h>
-#include <math.h>
+#include <al/math.h>
#include "view.h"
@@ -13,6 +13,9 @@
#define Y_IN_BOUNDS(view, h, y) \
((y + PAN_BOUNDS <= h) && (PAN_BOUNDS - y <= view->height * view->zoom * view->stretch))
+#define FOV_MIN AL_TO_RADIANS(10.0)
+#define FOV_MAX AL_TO_RADIANS(160.0)
+
#define VIEW_ON_ITS_SIDE(view) \
(view->rotation % CAMU_VIEW_ROTATION_180 == CAMU_VIEW_ROTATION_90)
@@ -24,7 +27,7 @@ void camu_view_init(struct camu_view *view, u32 width, u32 height)
view->height = height;
view->zindex = 0;
view->stretch = 1.0;
- view->fov = M_PI / 2.25; // 80 degrees
+ view->fov = AL_TO_RADIANS(80.0);
}
void camu_view_calculate(struct camu_view *view, u32 window_width, u32 window_height)
@@ -95,7 +98,7 @@ bool camu_view_zoom_simple(struct camu_view *view, u32 window_width, u32 window_
f64 zoom = view->zoom + (v * view->zoom);
if (zoom > ZOOM_MAX || zoom < ZOOM_MIN) return false;
- x = view->x_offset - x_ratio * ((window_width * zoom) - prev_width);
+ x = view->x_offset - x_ratio * ((window_width * zoom) - prev_width);
y = view->y_offset - y_ratio * ((window_height * zoom) - prev_height);
if (!(X_IN_BOUNDS(view, window_width, x) && Y_IN_BOUNDS(view, window_height, y))) {
return false;
@@ -107,3 +110,11 @@ bool camu_view_zoom_simple(struct camu_view *view, u32 window_width, u32 window_
return true;
}
+
+bool camu_view_zoom_fov(struct camu_view *view, f64 v)
+{
+ f64 fov = view->fov - v;
+ if (fov > FOV_MAX || fov < FOV_MIN) return false;
+ view->fov = fov;
+ return true;
+}
diff --git a/src/screen/view.h b/src/screen/view.h
index b5f948e..2fc1742 100644
--- a/src/screen/view.h
+++ b/src/screen/view.h
@@ -34,5 +34,6 @@ struct camu_view {
void camu_view_init(struct camu_view *view, u32 width, u32 height);
void camu_view_calculate(struct camu_view *view, u32 window_width, u32 window_height);
-bool camu_view_pan_simple(struct camu_view *view, u32 window_width, u32 window_height, f64 dx, f64 dy);
bool camu_view_zoom_simple(struct camu_view *view, u32 window_width, u32 window_height, f64 x, f64 y, f64 v);
+bool camu_view_pan_simple(struct camu_view *view, u32 window_width, u32 window_height, f64 dx, f64 dy);
+bool camu_view_zoom_fov(struct camu_view *view, f64 v);
diff --git a/src/util/color_palette.c b/src/util/color_palette.c
index fc26a10..09c3ae3 100644
--- a/src/util/color_palette.c
+++ b/src/util/color_palette.c
@@ -38,7 +38,7 @@ bool camu_color_palette_init(str *path)
{
#ifdef NAUNET_HAS_JSON
struct nn_file file;
- if (!nn_file_open(&file, path, NNWT_FILE_READONLY)) {
+ if (!nn_file_exists(path) || !nn_file_open(&file, path, NNWT_FILE_READONLY)) {
return false;
}
diff --git a/subprojects/libalabaster.wrap b/subprojects/libalabaster.wrap
index 9a9cfc1..425ed78 100644
--- a/subprojects/libalabaster.wrap
+++ b/subprojects/libalabaster.wrap
@@ -1,4 +1,4 @@
[wrap-git]
url = https://git.akon.city/libalabaster
-revision = 0470ca3416388e7e58b64f63bfe505d454f150b9
+revision = 1aa0177d1eb3767f1a7840115c3afeb93e8c858e
depth = 1
diff --git a/subprojects/libnaunet.wrap b/subprojects/libnaunet.wrap
index 15dd478..a069af9 100644
--- a/subprojects/libnaunet.wrap
+++ b/subprojects/libnaunet.wrap
@@ -1,4 +1,4 @@
[wrap-git]
url = https://git.akon.city/libnaunet
-revision = 3ea974dd51b1f01483bad6a4d115189928bbf4db
+revision = eaf84a7850f2af70b16fb417cd002f5e39df5db2
depth = 1
diff --git a/subprojects/miniaudio.wrap b/subprojects/miniaudio.wrap
index 4d577fa..dc57800 100644
--- a/subprojects/miniaudio.wrap
+++ b/subprojects/miniaudio.wrap
@@ -1,7 +1,7 @@
[wrap-git]
-directory = miniaudio-47020e4
+directory = miniaudio-e543369
url = https://github.com/mackron/miniaudio.git
-revision = 47020e4092ddf7cc9dd464fa7c1f7bd462e39a40
+revision = e54336996d3f57f2d47f80013049fd7559031778
depth = 1
patch_directory = miniaudio
diff_files = miniaudio/miniaudio_pulse_flush.diff, miniaudio/miniaudio_wasapi_drain_tweak.diff
diff --git a/subprojects/packagefiles/miniaudio/meson.build b/subprojects/packagefiles/miniaudio/meson.build
index 3c9e4c2..1bd17f5 100644
--- a/subprojects/packagefiles/miniaudio/meson.build
+++ b/subprojects/packagefiles/miniaudio/meson.build
@@ -1,2 +1,2 @@
-project('miniaudio', 'c', version: 'git-47020e4')
+project('miniaudio', 'c', version: 'git-e543369')
miniaudio = declare_dependency(include_directories: include_directories('.'))
diff --git a/subprojects/stela.wrap b/subprojects/stela.wrap
index 9f5c62b..da7a1c0 100644
--- a/subprojects/stela.wrap
+++ b/subprojects/stela.wrap
@@ -1,4 +1,4 @@
[wrap-git]
url = https://git.akon.city/stela
-revision = 01637283b33d7fe4921e9347ada867e44b29edba
+revision = f595bcadb2305d5bbc5ea945935a42bcfbf6bdb1
depth = 1