10 Commits

Author SHA1 Message Date
VC
937cb38ec4 Merge branch 'fix/video+images' into 'main'
All checks were successful
rust-check / rust-check (push) Successful in 5m45s
🚑️: version boost

See merge request veretcle/oolatoocs!44
2025-12-03 20:35:50 +00:00
VC
8123ed429e 🚑️: version boost 2025-12-03 21:29:23 +01:00
VC
85d469f325 Merge branch 'fix/video+images' into 'main'
Some checks failed
rust-check / rust-check (push) Has been cancelled
🚑️: fix record embed not properly generated

See merge request veretcle/oolatoocs!43
2025-12-03 20:01:08 +00:00
VC
e1a980ec81 🚑️: fix record embed not properly generated 2025-12-03 20:56:11 +01:00
VC
9cda013272 Merge branch 'ci/add_gitea_workflows' into 'main'
ci: add gitea workflow

See merge request veretcle/oolatoocs!42
2025-12-03 10:01:31 +00:00
VC
aee880e4bb 👷: add gitea workflow 2025-12-03 10:54:47 +01:00
VC
498095d3a8 Merge branch '17-better-handling-of-embedded-records' into 'main'
♻️: better handling of embedded records

Closes #17

See merge request veretcle/oolatoocs!41
2025-12-02 14:03:04 +00:00
VC
5ee64014eb ♻️: better handling of embedded records 2025-12-02 13:37:53 +01:00
VC
639582ba59 Merge branch '16-add-aspectratio-to-image-handling' into 'main'
: copy aspect ratio for images & video

Closes #16

See merge request veretcle/oolatoocs!40
2025-12-01 15:48:36 +00:00
VC
43ca862d5a : copy aspect ratio for images & video 2025-12-01 16:39:37 +01:00
7 changed files with 294 additions and 109 deletions

View File

@@ -0,0 +1,20 @@
---
name: rust-check
on: # yamllint disable-line rule:truthy
- push
jobs:
rust-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: "clippy,rustfmt"
- run: cargo fmt -- --check
- run: cargo check
- run: cargo clippy -- -D warnings
- run: cargo test
- run: cargo build

View File

@@ -0,0 +1,23 @@
---
name: rust-release
on: # yamllint disable-line rule:truthy
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- name: Get package name
run: echo "CARGO_PKG_NAME=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].name')" >> $GITEA_ENV
- run: cargo build --release
- uses: https://gitea.com/actions/gitea-release-action@v1
with:
files: |-
target/release/${{ env.CARGO_PKG_NAME }}
api_key: '${{ secrets.RELEASE_TOKEN }}'

2
Cargo.lock generated
View File

@@ -1926,7 +1926,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]] [[package]]
name = "oolatoocs" name = "oolatoocs"
version = "4.4.2" version = "4.5.3"
dependencies = [ dependencies = [
"atrium-api", "atrium-api",
"bsky-sdk", "bsky-sdk",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "oolatoocs" name = "oolatoocs"
version = "4.4.2" version = "4.5.3"
edition = "2021" edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

View File

@@ -1,4 +1,4 @@
use crate::{config::BlueskyConfig, OolatoocsError}; use crate::{config::BlueskyConfig, utils::convert_aspect_ratio, OolatoocsError};
use atrium_api::{ use atrium_api::{
app::bsky::feed::post::RecordData, com::atproto::repo::upload_blob::Output, app::bsky::feed::post::RecordData, com::atproto::repo::upload_blob::Output,
types::string::Datetime, types::string::Language, types::string::RecordKey, types::string::Datetime, types::string::Language, types::string::RecordKey,
@@ -148,9 +148,96 @@ async fn get_record(
Ok(record) Ok(record)
} }
/// Generate a Union of embed records to be built-in into records
/// In case an embed cannot be uploaded/created, this calling function silently gets Option instead
/// of failing
pub async fn generate_embed_records(
config: &BlueskyConfig,
bsky: &BskyAgent,
qid: Option<&str>,
media_attach: &[Attachment],
card: &Option<Card>,
) -> Result<
Option<atrium_api::types::Union<atrium_api::app::bsky::feed::post::RecordEmbedRefs>>,
Box<dyn Error + Send + Sync>,
> {
// handle quote if any
let quote_embed = match qid {
Some(q) => generate_quote_records(config, q).await.ok(),
_ => None,
};
// handle medias if any
let media_embed = if media_attach.len() > usize::from(0u8) {
let image_media_attach: Vec<_> = media_attach
.iter()
.filter(|x| x.r#type == AttachmentType::Image)
.cloned()
.collect();
let video_media_attach: Vec<_> = media_attach
.iter()
.filter(|x| x.r#type == AttachmentType::Video || x.r#type == AttachmentType::Gifv)
.cloned()
.collect();
if !video_media_attach.is_empty() {
generate_video_record(bsky, video_media_attach).await.ok()
} else if !image_media_attach.is_empty() {
generate_images_records(bsky, image_media_attach).await.ok()
} else {
return Err(OolatoocsError::new("A media attached is not an image nor a video").into());
}
} else {
None
};
// handle webcard if any
let webcard_embed = match card {
Some(t) => generate_webcard_records(bsky, t).await.ok(),
None => None,
};
let record_embed = if let Some(q) = quote_embed {
if let Some(m) = media_embed {
let medias_mapped = match m {
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedImagesMain(a) => atrium_api::app::bsky::embed::record_with_media::MainMediaRefs::AppBskyEmbedImagesMain(a),
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedVideoMain(a) => atrium_api::app::bsky::embed::record_with_media::MainMediaRefs::AppBskyEmbedVideoMain(a),
_ => return Err(OolatoocsError::new("Something went terribly wrong when trying to add image/video to quote record: cant decapsulate media").into()),
};
let quote_mapped = match q {
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedRecordMain(
a,
) => a,
_ => return Err(OolatoocsError::new("Something went terribly wrong when trying to add image/video to quote record: cant decapsulate quote").into()),
};
Some(atrium_api::types::Union::Refs(
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedRecordWithMediaMain(
Box::new(
atrium_api::app::bsky::embed::record_with_media::MainData {
media: atrium_api::types::Union::Refs(medias_mapped),
record: (*quote_mapped),
}
.into(),
),
),
))
} else {
Some(atrium_api::types::Union::Refs(q))
}
} else if media_embed.is_some() {
media_embed.map(atrium_api::types::Union::Refs)
} else if webcard_embed.is_some() {
webcard_embed.map(atrium_api::types::Union::Refs)
} else {
None
};
Ok(record_embed)
}
/// Generate an quote embed record /// Generate an quote embed record
/// it is encapsulated in Option to prevent this function from failing /// it is encapsulated in Option to prevent this function from failing
pub async fn generate_quote_records( async fn generate_quote_records(
config: &BlueskyConfig, config: &BlueskyConfig,
quote_id: &str, quote_id: &str,
) -> Result<atrium_api::app::bsky::feed::post::RecordEmbedRefs, Box<dyn Error>> { ) -> Result<atrium_api::app::bsky::feed::post::RecordEmbedRefs, Box<dyn Error>> {
@@ -173,7 +260,7 @@ pub async fn generate_quote_records(
/// Generate an embed webcard record into Bsky /// Generate an embed webcard record into Bsky
/// If the preview image does not exist or fails to upload, it is simply ignored /// If the preview image does not exist or fails to upload, it is simply ignored
pub async fn generate_webcard_records( async fn generate_webcard_records(
bsky: &BskyAgent, bsky: &BskyAgent,
card: &Card, card: &Card,
) -> Result<atrium_api::app::bsky::feed::post::RecordEmbedRefs, Box<dyn Error + Send + Sync>> { ) -> Result<atrium_api::app::bsky::feed::post::RecordEmbedRefs, Box<dyn Error + Send + Sync>> {
@@ -199,45 +286,12 @@ pub async fn generate_webcard_records(
) )
} }
/// Generate an array of Bsky media records /// Generate an array of Bsky image media records
/// As Bsky does not support multiple video in a record or mix of video and images, video has the async fn generate_images_records(
/// highest priority
pub async fn generate_media_records(
bsky: &BskyAgent, bsky: &BskyAgent,
media_attach: &[Attachment], media_attach: Vec<Attachment>,
) -> Result<atrium_api::app::bsky::feed::post::RecordEmbedRefs, Box<dyn Error + Send + Sync>> { ) -> Result<atrium_api::app::bsky::feed::post::RecordEmbedRefs, Box<dyn Error + Send + Sync>> {
let image_media_attach: Vec<_> = media_attach let mut stream = stream::iter(media_attach)
.iter()
.filter(|x| x.r#type == AttachmentType::Image)
.cloned()
.collect();
let video_media_attach: Vec<_> = media_attach
.iter()
.filter(|x| x.r#type == AttachmentType::Video || x.r#type == AttachmentType::Gifv)
.cloned()
.collect();
// Bsky only tasks 1 video per post, so well try to treat that first and exit
if !video_media_attach.is_empty() {
// treat only the very first video, ignore the rest
let media = &video_media_attach[0];
let blob = upload_media(false, bsky, &media.url).await?;
return Ok(
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedVideoMain(Box::new(
atrium_api::app::bsky::embed::video::MainData {
alt: media.description.clone(),
aspect_ratio: None,
captions: None,
video: blob.data.blob,
}
.into(),
)),
);
}
// It wasnt a video, then its an image or a gallery of 4 images
let mut stream = stream::iter(image_media_attach)
.map(|media| { .map(|media| {
let bsky = bsky.clone(); let bsky = bsky.clone();
tokio::task::spawn(async move { tokio::task::spawn(async move {
@@ -248,7 +302,9 @@ pub async fn generate_media_records(
.description .description
.clone() .clone()
.map_or("".to_string(), |v| v.to_owned()), .map_or("".to_string(), |v| v.to_owned()),
aspect_ratio: None, aspect_ratio: convert_aspect_ratio(
&media.meta.as_ref().and_then(|m| m.original.clone()),
),
image: i.data.blob, image: i.data.blob,
} }
}) })
@@ -277,6 +333,30 @@ pub async fn generate_media_records(
Err(OolatoocsError::new("Cannot embed media").into()) Err(OolatoocsError::new("Cannot embed media").into())
} }
/// Generate a video Bsky media record
async fn generate_video_record(
bsky: &BskyAgent,
media_attach: Vec<Attachment>,
) -> Result<atrium_api::app::bsky::feed::post::RecordEmbedRefs, Box<dyn Error + Send + Sync>> {
// treat only the very first video, ignore the rest
let media = &media_attach[0];
let blob = upload_media(false, bsky, &media.url).await?;
Ok(
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedVideoMain(Box::new(
atrium_api::app::bsky::embed::video::MainData {
alt: media.description.clone(),
aspect_ratio: convert_aspect_ratio(
&media.meta.as_ref().and_then(|m| m.original.clone()),
),
captions: None,
video: blob.data.blob,
}
.into(),
)),
)
}
async fn upload_media( async fn upload_media(
is_image: bool, is_image: bool,
bsky: &BskyAgent, bsky: &BskyAgent,

View File

@@ -18,10 +18,7 @@ mod utils;
use utils::{generate_multi_tweets, strip_everything}; use utils::{generate_multi_tweets, strip_everything};
mod bsky; mod bsky;
use bsky::{ use bsky::{build_post_record, generate_embed_records, get_session, BskyReply};
build_post_record, generate_media_records, generate_quote_records, generate_webcard_records,
get_session, BskyReply,
};
use rusqlite::Connection; use rusqlite::Connection;
@@ -161,69 +158,24 @@ pub async fn run(config: &Config) {
}); });
}; };
// handle quote if any // get quote_id if any
let quote_embed = match toot.reblog { let quote_id = match toot.reblog {
Some(r) => { Some(r) => match read_state(&conn, Some(r.id.parse::<u64>().unwrap())) {
let quote_record = read_state(&conn, Some(r.id.parse::<u64>().unwrap())); Ok(q) => q.map(|x| x.record_uri.to_owned()),
match quote_record { _ => None,
Ok(Some(q)) => generate_quote_records(&config.bluesky, &q.record_uri) },
.await
.ok(),
_ => None,
}
}
None => None, None => None,
}; };
// handle medias if any let record_embed = generate_embed_records(
let media_embed = if toot.media_attachments.len() > usize::from(0u8) { &config.bluesky,
generate_media_records(&bluesky, &toot.media_attachments) &bluesky,
.await quote_id.as_deref(),
.ok() &toot.media_attachments,
} else { &toot.card,
None )
}; .await
.unwrap_or_else(|e| panic!("Cannot embed record for {}: {}", &toot.id, e));
// handle webcard if any
let webcard_embed = match toot.card {
Some(t) => generate_webcard_records(&bluesky, &t).await.ok(),
None => None,
};
let record_embed = if quote_embed.is_some() {
if media_embed.is_some() {
let medias_mapped = match media_embed.unwrap() {
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedImagesMain(a) => atrium_api::app::bsky::embed::record_with_media::MainMediaRefs::AppBskyEmbedImagesMain(a),
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedVideoMain(a) => atrium_api::app::bsky::embed::record_with_media::MainMediaRefs::AppBskyEmbedVideoMain(a),
_ => continue, // this should NEVER happen as Media are either Video or
// Images at this point
};
let quote_mapped = match quote_embed.unwrap() {
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedRecordMain(
a,
) => a,
_ => continue, // again, this should NEVER happen
};
Some(atrium_api::types::Union::Refs(
atrium_api::app::bsky::feed::post::RecordEmbedRefs::AppBskyEmbedRecordWithMediaMain(
Box::new(
atrium_api::app::bsky::embed::record_with_media::MainData {
media: atrium_api::types::Union::Refs(medias_mapped),
record: (*quote_mapped),
}.into()
)
)
))
} else {
quote_embed.map(atrium_api::types::Union::Refs)
}
} else if media_embed.is_some() {
media_embed.map(atrium_api::types::Union::Refs)
} else if webcard_embed.is_some() {
webcard_embed.map(atrium_api::types::Union::Refs)
} else {
None
};
// posts corresponding tweet // posts corresponding tweet
let record = build_post_record( let record = build_post_record(

View File

@@ -1,7 +1,8 @@
use atrium_api::{app::bsky::embed::defs::AspectRatioData, types::Object};
use html_escape::decode_html_entities; use html_escape::decode_html_entities;
use megalodon::entities::status::Tag; use megalodon::entities::{attachment::MetaSub, status::Tag};
use regex::Regex; use regex::Regex;
use std::error::Error; use std::{error::Error, num::NonZeroU64};
/// Generate 2 contents out of 1 if that content is > 300 chars, None else /// Generate 2 contents out of 1 if that content is > 300 chars, None else
pub fn generate_multi_tweets(content: &str) -> Option<(String, String)> { pub fn generate_multi_tweets(content: &str) -> Option<(String, String)> {
@@ -110,10 +111,119 @@ fn strip_html_tags(input: &str) -> String {
data data
} }
pub fn convert_aspect_ratio(m: &Option<MetaSub>) -> Option<Object<AspectRatioData>> {
match m {
Some(ms) => {
if ms.height.is_some_and(|x| x > 0) && ms.width.is_some_and(|x| x > 0) {
Some(
AspectRatioData {
// unwrap is safe here
height: NonZeroU64::new(ms.height.unwrap().into()).unwrap(),
width: NonZeroU64::new(ms.width.unwrap().into()).unwrap(),
}
.into(),
)
} else {
None
}
}
None => None,
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn test_convert_aspect_ratio() {
// test None orig aspect ratio
let metasub: Option<MetaSub> = None;
let result = convert_aspect_ratio(&metasub);
assert_eq!(result, None);
// test complet with image
let metasub = Some(MetaSub {
width: Some(1920),
height: Some(1080),
size: Some(String::from("1920x1080")),
aspect: Some(1.7777777777777777),
frame_rate: None,
duration: None,
bitrate: None,
});
let expected_result = Some(
AspectRatioData {
height: NonZeroU64::new(1080).unwrap(),
width: NonZeroU64::new(1920).unwrap(),
}
.into(),
);
let result = convert_aspect_ratio(&metasub);
assert_eq!(result, expected_result);
// test complete with video
let metasub = Some(MetaSub {
width: Some(500),
height: Some(278),
size: None,
aspect: None,
frame_rate: Some(String::from("10/1")),
duration: Some(0.9),
bitrate: Some(973191),
});
let expected_result = Some(
AspectRatioData {
height: NonZeroU64::new(278).unwrap(),
width: NonZeroU64::new(500).unwrap(),
}
.into(),
);
let result = convert_aspect_ratio(&metasub);
assert_eq!(result, expected_result);
/* test broken shit
* that should never happened but you never know
*/
// zero width
let metasub = Some(MetaSub {
width: Some(0),
height: Some(278),
size: None,
aspect: None,
frame_rate: Some(String::from("10/1")),
duration: Some(0.9),
bitrate: Some(973191),
});
let result = convert_aspect_ratio(&metasub);
assert_eq!(result, None);
// None height
let metasub = Some(MetaSub {
width: Some(500),
height: None,
size: None,
aspect: None,
frame_rate: Some(String::from("10/1")),
duration: Some(0.9),
bitrate: Some(973191),
});
let result = convert_aspect_ratio(&metasub);
assert_eq!(result, None);
}
#[test] #[test]
fn test_twitter_count() { fn test_twitter_count() {
let content = "tamerelol?! 🐵"; let content = "tamerelol?! 🐵";