This commit is contained in:
Marc Plano-Lesay 2025-04-24 22:27:11 +10:00
commit 2892a0d272
Signed by: kernald
GPG key ID: 66A41B08CC62A6CF
11 changed files with 2641 additions and 0 deletions

4
.envrc Normal file
View file

@ -0,0 +1,4 @@
if ! has nix_direnv_version || ! nix_direnv_version 2.2.1; then
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/2.2.1/direnvrc" "sha256-zelF0vLbEl5uaqrfIzbgNzJWGmLzCmYAkInj/LNxvKs="
fi
use flake .

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
.direnv/
.idea/
/target
*.act
*.bmp
*.jpg
*.png

62
.gitlab-ci.yml Normal file
View file

@ -0,0 +1,62 @@
---
stages:
- test
- build
- publish
default:
tests:
stage: test
image: rust
before_script:
- rustc --version
- cargo --version
- rustup component add rustfmt
script:
- cargo fmt --check
- cargo test
build:amd64:
stage: build
image: rust
before_script:
- rustc --version
- cargo --version
script:
- cargo build --release
artifacts:
paths:
- target/release/palette-bin
rustdoc:
stage: build
image: rust
before_script:
- rustc --version
- cargo --version
script:
- cargo doc
artifacts:
paths:
- target/doc
pages:
stage: publish
image: alpine
dependencies:
- build:amd64
- rustdoc
script:
- mkdir -p public
- mv target/doc public/doc
- mv target/release/palette-bin public/palette-amd64
artifacts:
paths:
- public
only:
- main
...

2084
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

24
Cargo.toml Normal file
View file

@ -0,0 +1,24 @@
[package]
name = "reddit-magnet"
version = "0.1.0"
edition = "2021"
license = "MIT"
description = "Monitor a Reddit user's posts for magnet links"
repository = "https://git.enoent.fr/kernald/reddit-magnet"
categories = ["command-line-utilities"]
[dependencies]
clap = { version = "4.5.32", features = ["derive"] }
roux = "2.2.14"
figment = { version = "0.10", features = ["toml", "json", "env"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.5"
tokio = { version = "1.44.2", features = ["rt", "rt-multi-thread", "macros"] }
regex = "1.10.3"
figment_file_provider_adapter = "0.1.1"
directories = "6.0.0"
log = "0.4.27"
color-eyre = "0.6.3"
chrono = { version = "0.4", features = ["serde"] }
multimap = "0.10.0"

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Marc Plano-Lesay
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

61
flake.lock generated Normal file
View file

@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1744932701,
"narHash": "sha256-fusHbZCyv126cyArUwwKrLdCkgVAIaa/fQJYFlCEqiU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "b024ced1aac25639f8ca8fdfc2f8c4fbd66c48ef",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

49
flake.nix Normal file
View file

@ -0,0 +1,49 @@
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs =
{ nixpkgs
, flake-utils
, ...
}:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs {
inherit system;
};
# Place all toolchain components in a single directory for IntelliJ
rust-toolchain = pkgs.symlinkJoin {
name = "rust-toolchain";
paths = with pkgs; [
rustc
cargo
rustPlatform.rustcSrc
] ++ lib.optionals stdenv.isDarwin [
libiconv
darwin.apple_sdk.frameworks.SystemConfiguration
];
};
in
{
devShells.default = pkgs.mkShell {
name = "reddit-magnet";
buildInputs = with pkgs; [
cargo
cargo-machete
cargo-release
cargo-sort
openssl
pkg-config
rustc
rust-toolchain
] ++ lib.optionals stdenv.isDarwin [
libiconv
darwin.apple_sdk.frameworks.SystemConfiguration
];
RUST_BACKTRACE = 1;
};
});
}

106
src/magnet.rs Normal file
View file

@ -0,0 +1,106 @@
pub type Magnet = String;
/// Extract magnet links from text
pub fn extract_magnet_links(text: &str) -> Vec<Magnet> {
let mut links = Vec::new();
let mut start_idx = 0;
while let Some(idx) = text[start_idx..].find("magnet:") {
let start = start_idx + idx;
let end = text[start..]
.find(char::is_whitespace)
.map(|e| start + e)
.unwrap_or(text.len());
links.push(text[start..end].to_string());
start_idx = end;
}
links
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_text() {
let text = "";
let links = extract_magnet_links(text);
assert!(links.is_empty());
}
#[test]
fn test_no_magnet_links() {
let text = "This is a text without any magnet links";
let links = extract_magnet_links(text);
assert!(links.is_empty());
}
#[test]
fn test_single_magnet_link() {
let text = "Here is a magnet link: magnet:?xt=urn:btih:example";
let links = extract_magnet_links(text);
assert_eq!(links.len(), 1);
assert_eq!(links[0], "magnet:?xt=urn:btih:example");
}
#[test]
fn test_multiple_magnet_links() {
let text = "First link: magnet:?xt=urn:btih:example1 and second link: magnet:?xt=urn:btih:example2";
let links = extract_magnet_links(text);
assert_eq!(links.len(), 2);
assert_eq!(links[0], "magnet:?xt=urn:btih:example1");
assert_eq!(links[1], "magnet:?xt=urn:btih:example2");
}
#[test]
fn test_magnet_link_at_beginning() {
let text = "magnet:?xt=urn:btih:example at the beginning";
let links = extract_magnet_links(text);
assert_eq!(links.len(), 1);
assert_eq!(links[0], "magnet:?xt=urn:btih:example");
}
#[test]
fn test_magnet_link_at_end() {
let text = "Link at the end: magnet:?xt=urn:btih:example";
let links = extract_magnet_links(text);
assert_eq!(links.len(), 1);
assert_eq!(links[0], "magnet:?xt=urn:btih:example");
}
#[test]
fn test_magnet_link_without_whitespace() {
let text = "Text containing a link:magnet:?xt=urn:btih:example";
let links = extract_magnet_links(text);
assert_eq!(links.len(), 1);
assert_eq!(links[0], "magnet:?xt=urn:btih:example");
}
#[test]
fn test_complex_magnet_link() {
let text = "Complex link: magnet:?xt=urn:btih:a1b2c3d4e5f6g7h8i9j0&dn=example+file&tr=udp%3A%2F%2Ftracker.example.com%3A80";
let links = extract_magnet_links(text);
assert_eq!(links.len(), 1);
assert_eq!(links[0], "magnet:?xt=urn:btih:a1b2c3d4e5f6g7h8i9j0&dn=example+file&tr=udp%3A%2F%2Ftracker.example.com%3A80");
}
}

180
src/main.rs Normal file
View file

@ -0,0 +1,180 @@
use chrono::{DateTime, Utc};
use clap::Parser;
use color_eyre::eyre::{eyre, Result, WrapErr};
use directories::ProjectDirs;
use figment::providers::Env;
use figment::{
providers::{Format, Toml},
Figment,
};
use figment_file_provider_adapter::FileAdapter;
use log::debug;
use multimap::MultiMap;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use reddit_client::RedditClient;
use crate::magnet::{extract_magnet_links, Magnet};
mod magnet;
mod reddit_client;
#[derive(Debug, Serialize, Deserialize)]
struct SectionConfig {
username: String,
title_filter: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct Config {
#[serde(flatten)]
sections: HashMap<String, SectionConfig>,
}
#[derive(Debug)]
struct PostInfo {
title: String,
magnet_links: Vec<Magnet>,
subreddit: String,
timestamp: DateTime<Utc>,
}
#[derive(Parser, Debug)]
#[command(author, version, about = "Display recent posts from a Reddit user")]
struct Args {
/// Path to the configuration file
#[arg(short, long)]
config: Option<String>,
}
/// Filters posts based on a title filter pattern
fn filter_posts(title: &str, title_filter: Option<Regex>) -> bool {
match title_filter {
Some(pattern) => pattern.is_match(title),
None => true,
}
}
/// Prints the posts with their magnet links
fn print_posts(posts: &[PostInfo], username: &str, title_filter: Option<Regex>) -> usize {
println!("Magnet links from u/{}:", username);
if let Some(pattern) = title_filter {
println!("Filtering titles by pattern: {}", pattern);
}
println!("----------------------------");
let mut post_count = 0;
for post in posts {
// Only display posts with magnet links
if !post.magnet_links.is_empty() {
post_count += 1;
println!(
"{}. [r/{}] {} (Posted: {})",
post_count,
post.subreddit,
post.title,
post.timestamp.format("%Y-%m-%d %H:%M:%S")
);
for (i, link) in post.magnet_links.iter().enumerate() {
println!(" Link {}: {}", i + 1, link);
}
println!();
}
}
if post_count == 0 {
println!("No posts with magnet links found.");
}
post_count
}
#[tokio::main]
async fn main() -> Result<()> {
color_eyre::install()?;
let args = Args::parse();
let mut conf_extractor = Figment::new();
let config_file_path: Option<PathBuf> = match args.config {
Some(path) => Some(Path::new(&path).to_path_buf()),
None => ProjectDirs::from("fr", "enoent", "reddit-magnet")
.map(|p| p.config_dir().join("config.toml")),
};
match config_file_path {
Some(path) => {
if path.exists() {
debug!("Reading configuration from {:?}", path);
conf_extractor = conf_extractor.merge(FileAdapter::wrap(Toml::file_exact(path)));
} else {
debug!("Configuration file doesn't exist at {:?}", path);
}
}
None => {
debug!("No configuration file specified, using default configuration");
}
}
let conf: Config = conf_extractor
.merge(FileAdapter::wrap(Env::prefixed("REDDIT_MAGNET_")))
.extract()
.wrap_err_with(|| "Invalid configuration or insufficient command line arguments")?;
if conf.sections.is_empty() {
return Err(eyre!("No configuration sections found. Please add at least one section to your configuration file.").into());
}
let mut unique_usernames = HashSet::new();
for (_, section_config) in &conf.sections {
unique_usernames.insert(section_config.username.clone());
}
let reddit_client = RedditClient::new();
let mut user_posts = MultiMap::new();
for username in unique_usernames {
let submissions = reddit_client.fetch_user_submissions(&username).await?;
user_posts.insert_many(username, submissions);
}
for (section_name, section_config) in conf.sections {
println!("\nProcessing section [{}]", section_name);
let username = section_config.username.clone();
let title_filter = match section_config.title_filter {
Some(filter) => Some(
Regex::new(filter.as_str())
.context(format!("Invalid regex pattern: {}", filter))?,
),
None => None,
};
if let Some(submissions) = user_posts.get_vec(&username) {
let mut filtered_posts = Vec::new();
for post in submissions.iter().filter(|s| filter_posts(&*s.title, title_filter.clone())) {
let title = &post.title;
let body = &post.body;
let subreddit = &post.subreddit;
let magnet_links = extract_magnet_links(body);
if !magnet_links.is_empty() {
filtered_posts.push(PostInfo {
title: title.to_string(),
subreddit: subreddit.to_string(),
magnet_links,
timestamp: post.created,
});
}
}
print_posts(&filtered_posts, &username, title_filter);
}
}
Ok(())
}

43
src/reddit_client.rs Normal file
View file

@ -0,0 +1,43 @@
use chrono::{DateTime, TimeZone, Utc};
use color_eyre::eyre::{Result, WrapErr};
use roux::User;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct RedditPost {
pub title: String,
pub body: String,
pub subreddit: String,
pub created: DateTime<Utc>,
}
/// A client for interacting with Reddit API
pub struct RedditClient;
impl RedditClient {
/// Create a new RedditClient
pub fn new() -> Self {
RedditClient
}
/// Fetch submissions for a user
pub async fn fetch_user_submissions(&self, username: &str) -> Result<Vec<RedditPost>> {
let user = User::new(username);
let submissions = user
.submitted(None)
.await
.context(format!("Failed to fetch submissions for user {}", username))?;
let mut children = Vec::new();
for post in submissions.data.children.iter() {
children.push(RedditPost {
title: post.data.title.clone(),
body: post.data.selftext.clone(),
subreddit: post.data.subreddit.clone(),
created: Utc.timestamp_opt(post.data.created_utc as i64, 0).unwrap(),
});
}
Ok(children)
}
}