root/src/booru/local.rs

// local.rs
//
// Copyright 2025 nee <nee-git@hidamari.blue>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program.  If not, see <http://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later

use crate::booru::Booru;
use crate::booru::Boorus;
use crate::booru::Post;
use crate::booru::Posts;
use anyhow::Result;
use serde::Deserialize;
use sqlx::Acquire;
use sqlx::Row;
use std::path::Path;
use std::path::PathBuf;
use std::sync::LazyLock;

pub static ID: LazyLock<String> = LazyLock::new(|| "__builtin_localbooru".to_string());

type DateTime = String;

#[derive(Deserialize, Debug, Clone, sqlx::FromRow, PartialEq, Eq)]
pub struct Tag {
    tag: String,
    r#type: u16,
}

// can't pass TagDB into the async context :pain:
#[derive(Debug, Clone)]
pub struct TagsForPost {
    pub tags_character: String,
    pub tags_copyright: String,
    pub tags_artist: String,
    pub tags_general: String,
    pub tags_meta: String,
    pub tags_unknown: String,
}

#[derive(Deserialize, Debug, Clone, sqlx::FromRow, PartialEq, Eq)]
pub struct LocalbooruPost {
    id: u64,
    local_file: Option<String>,
    local_thumb: Option<String>,
    download_date: Option<DateTime>,
    upload_date: Option<DateTime>,
    view_date: DateTime,

    width: u32,
    height: u32,

    // Space separated tag strings
    tags_character: String,
    tags_copyright: String,
    tags_artist: String,
    tags_general: String,
    tags_meta: String,
    tags_unknown: String,

    hash: String,
    image_url: String,
    web_url: String,
    source_url: Option<String>,

    // -- Id in the booru
    booru_id: String,
    // -- domain of the booru
    booru: String,
    // #[serde(skip_deserializing)]
    // tags: Vec<Tag>,
}

impl Post for LocalbooruPost {
    fn id(&self) -> u64 {
        self.id
    }
    fn sort_id(&self) -> u64 {
        self.id
    }
    fn width(&self) -> u32 {
        // self.width
        crate::DEFAULT_THUMB_SIZE_IMAGE as u32 // 118px
    }
    fn height(&self) -> u32 {
        // self.height
        crate::DEFAULT_THUMB_SIZE_IMAGE as u32 // 118px
    }
    fn sample_url(&self) -> String {
        "".to_string()
    }
    fn thumb_url(&self) -> String {
        self.local_thumb.clone().unwrap_or("".to_string())
    }
    fn full_url(&self) -> String {
        self.local_file.clone().unwrap_or("".to_string())
    }
    fn filename(&self) -> String {
        self.local_file.clone().unwrap_or("".to_string())
        // std::path::Path::new(&self.local_file.clone().unwrap_or("".to_string())).file_name().to_string()
    }
    fn tags(&self) -> Vec<&str> {
        [
            self.tags_character.split(' ').collect::<Vec<_>>(),
            self.tags_copyright.split(' ').collect::<Vec<_>>(),
            self.tags_artist.split(' ').collect::<Vec<_>>(),
            self.tags_general.split(' ').collect::<Vec<_>>(),
            self.tags_meta.split(' ').collect::<Vec<_>>(),
            self.tags_unknown.split(' ').collect::<Vec<_>>(),
        ]
        .concat()
    }
    fn web_url(&self) -> String {
        self.web_url.to_string()
    }
    fn domain(&self) -> &String {
        &ID
    }
    fn clone_post(&self) -> Box<dyn Post> {
        std::boxed::Box::new(self.clone())
    }

    fn imp(&self) -> Posts {
        Posts::Localbooru(self.clone())
    }
    fn is_local(&self) -> bool {
        true
    }
}

// Painful pair, because can't pass tagdb to async
impl From<(&Box<dyn Post>, TagsForPost)> for LocalbooruPost {
    fn from(pair: (&Box<dyn Post>, TagsForPost)) -> Self {
        let (post, tags) = pair;
        Self {
            id: 0,
            local_file: None,
            local_thumb: None,
            download_date: None,
            upload_date: None,
            view_date: "".to_string(),
            hash: post.filename(),
            image_url: post.full_url(),
            web_url: post.web_url(),
            source_url: None,

            width: post.width(),
            height: post.height(),

            tags_character: tags.tags_character,
            tags_copyright: tags.tags_copyright,
            tags_artist: tags.tags_artist,
            tags_general: tags.tags_general,
            tags_meta: tags.tags_meta,
            tags_unknown: tags.tags_unknown,

            // -- Id in the booru
            booru_id: format!("{}", post.id()),
            // -- domain of the booru
            booru: post.domain().to_string(),
            // #[serde(skip_deserializing)]
            // tags: Vec<Tag>,
        }
    }
}

#[derive(Deserialize, Debug, Default, Clone)]
pub struct Localbooru {}

impl Booru for Localbooru {
    fn next_page(&self, current_page: u32, _last_result_count: u32) -> u32 {
        current_page + 1
    }
    fn clone_booru(&self) -> Box<dyn Booru> {
        Box::new(self.clone())
    }
    fn check_api_by_domain(&self, hash: u64, _domain: &str) -> bool {
        hash == 184627908585081862
    }
    fn get_domain(&self) -> &String {
        &ID
    }
    fn imp(&self) -> Boorus {
        Boorus::Localbooru(self.clone())
    }
    fn is_local(&self) -> bool {
        true
    }
}

const POSTS_PER_PAGE: u32 = 100;

impl Localbooru {
    pub async fn fetch_index(
        &self,
        search: &str,
        page: u32,
    ) -> Result<std::vec::Vec<Box<dyn Post>>> {
        // let posts = sqlx::query_as!(LocalbooruPost, "SELECT * FROM posts", ).fetch_multiple(&mut conn).await?;
        println!("gonna fetch local booru");
        let mut conn = crate::POOL.acquire().await?;

        if !search.is_empty() {
            let tags = search.split(' ');
            let mut query: sqlx::QueryBuilder<sqlx::Sqlite> = sqlx::QueryBuilder::new(
                "SELECT id, local_file, local_thumb, width, height,
    tags_character,
    tags_copyright,
    tags_artist,
    tags_general,
    tags_meta,
    tags_unknown,
download_date, upload_date, view_date, hash, image_url, web_url, source_url, booru_id, booru
FROM post_tags JOIN post ON post_id = post.id
WHERE",
            );
            let mut count = 1;
            let mut iter = tags.into_iter();
            query.push(" tag = ");
            query.push_bind(iter.next().unwrap());

            for tag in iter {
                if !tag.is_empty() {
                    query.push(" OR tag = ");
                    query.push_bind(tag);
                    count += 1;
                }
            }

            query.push("\nGROUP BY post.id HAVING COUNT(DISTINCT tag) = ");
            query.push_bind(count);
            // pagination
            query.push("\nORDER BY ID DESC\nLIMIT ");
            query.push_bind(POSTS_PER_PAGE);
            query.push("\nOFFSET ");
            query.push_bind(page * POSTS_PER_PAGE);

            // uncomment for debug print of the SQL text
            // let sql = query.build().sql();
            // println!("{}",sql);
            // let posts: Vec<LocalbooruPost> = sqlx::query_as(sql).fetch_all(&mut *conn).await?;

            let posts: Vec<LocalbooruPost> = query.build_query_as().fetch_all(&mut *conn).await?;

            println!("fetched local booru, {}", posts.len());
            Ok(posts
                .into_iter()
                .map(|p| Box::<dyn Post>::from(Box::new(p)))
                .collect())
        } else {
            // This is not fine.
            let posts: Vec<LocalbooruPost> = sqlx::query_as("SELECT id, local_file, local_thumb, width, height,
    tags_character,
    tags_copyright,
    tags_artist,
    tags_general,
    tags_meta,
    tags_unknown,
download_date, upload_date, view_date, hash, image_url, web_url, source_url, booru_id, booru FROM post
ORDER BY ID DESC
LIMIT ?
OFFSET ?
")
.bind(POSTS_PER_PAGE)
.bind(page*POSTS_PER_PAGE)
.fetch_all(&mut *conn).await?;

            println!("fetched local booru, {}", posts.len());
            Ok(posts
                .into_iter()
                .map(|p| Box::<dyn Post>::from(Box::new(p)))
                .collect())
        }
        // Ok(vec![])
    }

    pub async fn post_already_saved(post: &dyn Post) -> Result<bool> {
        let mut conn = crate::POOL.acquire().await?;
        let result = sqlx::query("SELECT 1 FROM post WHERE booru=? AND booru_id=?")
            .bind(post.domain())
            .bind(format!("{}", post.id()))
            .fetch_one(&mut *conn)
            .await?;
        let i: i32 = result.try_get(0)?;
        println!("laready saved {}", i == 1);
        Ok(i == 1)
    }

    pub async fn post_saved(
        post: Box<dyn Post>,
        filename: &Path,
        thumb_path: &Option<PathBuf>,
        tags_for_post: TagsForPost,
    ) -> Result<()> {
        println!("gonna save a post");

        let mut post = LocalbooruPost::from((&post, tags_for_post));
        post.local_file = filename.to_str().map(str::to_string);
        post.local_thumb = thumb_path
            .as_ref()
            .and_then(|s| s.to_str().map(str::to_string));

        let mut conn = crate::POOL.acquire().await?;
        let mut tx = conn.begin().await?;

        // I should have stuck to diesel, WTF do people recommend this????
        // No binding of structs 😫😖😩
        let r = sqlx::query("INSERT INTO post (id, local_file, local_thumb, width, height,
    tags_character,
    tags_copyright,
    tags_artist,
    tags_general,
    tags_meta,
    tags_unknown,
    download_date, upload_date, view_date, hash, image_url, web_url, source_url, booru_id, booru) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
        // living like animals
            .bind(None::<i64>)
            .bind(&post.local_file)
            .bind(&post.local_thumb)
            .bind(post.width)
            .bind(post.height)
        // 🤢
            .bind(&post.tags_character)
            .bind(&post.tags_copyright)
            .bind(&post.tags_artist)
            .bind(&post.tags_general)
            .bind(&post.tags_meta)
            .bind(&post.tags_unknown)
        // 🤢🤢
            .bind(&post.download_date)
            .bind(&post.upload_date)
            .bind(&post.view_date)
        // 🤢🤢🤢
            .bind(&post.hash)
            .bind(&post.image_url)
            .bind(&post.web_url)
            .bind(&post.source_url)
            .bind(&post.booru_id)
            .bind(&post.booru)
        // 🤮
            .execute(&mut *tx).await;

        println!("{:#?}", r);
        // tag -> post relation for searching
        let post_id = r.map(|r| r.last_insert_rowid())?;
        for tag in post.tags() {
            if !tag.is_empty() {
                let _ = sqlx::query("INSERT INTO post_tags (post_id, tag) VALUES (?, ?)")
                    .bind(post_id)
                    .bind(tag)
                    .execute(&mut *tx)
                    .await;
            }
        }

        tx.commit().await?;
        println!("saved!");
        Ok(())
    }
}