Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff7e46f2c5 | |||
| eca382f0e3 | |||
|
|
c978b9a6df | ||
| 733b93efa4 | |||
| 774fc4a0f5 | |||
| 4fec70334d | |||
| d130a9bde3 | |||
| f6254611a1 | |||
| 08f6ee9acd | |||
| b076fc6abd | |||
| a82be761ed | |||
| 21535d4b1e | |||
| d6a3f31543 | |||
| a13cef840e | |||
| 93f8244d08 | |||
| 10cb548f8c | |||
| 928d426a0f | |||
| 3807bec1ef | |||
| d98cc83539 | |||
| a1b5c74811 | |||
| 2ae4929107 | |||
| 0fadb8caa5 | |||
|
|
f04fb927d9 | ||
| a50e6069c2 | |||
|
|
841420ddd8 | ||
| f290b37db9 | |||
| e8d72f4db6 | |||
| 45ee778cdf | |||
| 6c0d4e5fc2 | |||
| 27de7776fe | |||
| 08e789e068 | |||
| fb5225fb36 | |||
| eafc292aa4 | |||
| 64fa1e95a2 | |||
| 5330cae35f | |||
| 429e5beb7c | |||
| c51f3c134b | |||
| f4207ae84e | |||
| cc5602a40c | |||
| 7e73b0f82a | |||
| 393210bc1a | |||
| 4a2dd2c02d | |||
| 34aa6800e3 | |||
| 69b408f5d7 | |||
| 2680abeb77 | |||
| b7259f73f2 | |||
| 7263272c7c | |||
| ecd2d36818 | |||
| aee487cdc8 | |||
| 90e6fa10e2 | |||
| 508dd68ed8 | |||
| 9749b58576 | |||
| d3c219a28f | |||
| f5439cf1fa | |||
| 2858985b59 | |||
| b2fd230539 | |||
| 47d635f233 | |||
| 7d947231af | |||
| c9a446e8d9 | |||
| 41aca2d0f4 | |||
| 0b3b80d775 |
54
.github/workflows/docker-build-dev.yml
vendored
Normal file
54
.github/workflows/docker-build-dev.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
env:
|
||||
REGISTRY: git.futuregadgetlabs.net
|
||||
IMAGE_NAME: kj16609/idhan
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.server_url == 'https://git.futuregadgetlabs.net'
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.futuregadgetlabs.net
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=raw,value=dev,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
|
||||
2
3rd-party/hydrui
vendored
2
3rd-party/hydrui
vendored
Submodule 3rd-party/hydrui updated: 769dfd6a27...3285cecb59
17
Dockerfile
17
Dockerfile
@@ -2,7 +2,8 @@
|
||||
# Stage 1: Build environment
|
||||
FROM ubuntu:24.04 AS builder
|
||||
|
||||
RUN apt-get update && DEBIAN_FRONTNED=noninteractive apt-get install -y \
|
||||
RUN apt-get update && \
|
||||
DEBIAN_FRONTNED=noninteractive apt-get install -y \
|
||||
build-essential \
|
||||
cmake \
|
||||
git \
|
||||
@@ -17,7 +18,11 @@ RUN apt-get update && DEBIAN_FRONTNED=noninteractive apt-get install -y \
|
||||
libqt6core6 \
|
||||
libqt6multimedia6 \
|
||||
libjsoncpp-dev \
|
||||
libvips-dev
|
||||
libvips-dev \
|
||||
libavcodec-dev \
|
||||
libavcodec-extra \
|
||||
libavfilter-dev \
|
||||
libavutil-dev
|
||||
|
||||
# Set C++23 capable compiler as default
|
||||
RUN update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-14 100 && \
|
||||
@@ -50,10 +55,9 @@ RUN --mount=type=cache,target=/root/.ccache \
|
||||
# Stage 2: Runtime environment
|
||||
FROM ubuntu:24.04
|
||||
|
||||
RUN apt-get update
|
||||
|
||||
# Install runtime dependencies only
|
||||
RUN DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
RUN apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
# Qt6 runtime libraries
|
||||
libqt6core6 \
|
||||
libqt6multimedia6 \
|
||||
@@ -68,7 +72,8 @@ RUN DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
uuid-runtime \
|
||||
zlib1g \
|
||||
libssl3 \
|
||||
libc-ares2
|
||||
libc-ares2 \
|
||||
ffmpeg
|
||||
|
||||
# Cleanup
|
||||
RUN apt-get clean
|
||||
|
||||
@@ -41,7 +41,7 @@ void FileRelationshipsWidget::updateText()
|
||||
if ( alternatives_processed > 0 || duplicates_processed > 0 )
|
||||
{
|
||||
ui->alternativesCount->setText(
|
||||
QString( "Alternative groups: %L1 (%L2 processed)" )
|
||||
QString( "Alternative files: %L1 (%L2 processed)" )
|
||||
.arg( alternatives_total )
|
||||
.arg( alternatives_processed ) );
|
||||
ui->duplicatesCount->setText(
|
||||
@@ -49,12 +49,16 @@ void FileRelationshipsWidget::updateText()
|
||||
}
|
||||
else
|
||||
{
|
||||
ui->alternativesCount->setText( QString( "Alternative groups: %L1" ).arg( alternatives_total ) );
|
||||
ui->alternativesCount->setText( QString( "Alternative files: %L1" ).arg( alternatives_total ) );
|
||||
ui->duplicatesCount->setText( QString( "Duplicate pairs: %L1" ).arg( duplicates_total ) );
|
||||
}
|
||||
|
||||
ui->progressBar->setMaximum( alternatives_total + duplicates_total );
|
||||
ui->progressBar->setValue( alternatives_processed + duplicates_processed );
|
||||
|
||||
if ( alternatives_processed + duplicates_processed == 0 )
|
||||
ui->progressBar->setValue( -1 );
|
||||
else
|
||||
ui->progressBar->setValue( alternatives_processed + duplicates_processed );
|
||||
}
|
||||
|
||||
void FileRelationshipsWidget::startImport()
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Name: Duplicate & Alternative relationships
|
||||
Type:File Relationships</string>
|
||||
Type: File Relationships</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
|
||||
@@ -175,7 +175,11 @@ void TagServiceWidget::updateProcessed()
|
||||
const auto over_limit { to_process > std::numeric_limits< int >::max() };
|
||||
const auto multip { over_limit ? 1024 : 1 };
|
||||
|
||||
ui->progressBar->setValue( static_cast< int >( total_processed / multip ) );
|
||||
if ( total_processed == 0 )
|
||||
ui->progressBar->setValue( -1 );
|
||||
else
|
||||
ui->progressBar->setValue( static_cast< int >( total_processed / multip ) );
|
||||
|
||||
ui->progressBar->setMaximum( static_cast< int >( to_process / multip ) );
|
||||
}
|
||||
|
||||
@@ -220,6 +224,7 @@ void TagServiceWidget::processedAliases( std::size_t count )
|
||||
void TagServiceWidget::preprocessingFinished()
|
||||
{
|
||||
m_preprocessed = true;
|
||||
updateProcessed();
|
||||
updateTime();
|
||||
}
|
||||
|
||||
|
||||
@@ -46,13 +46,13 @@ void UrlServiceWidget::statusMessage( const QString& msg )
|
||||
|
||||
void UrlServiceWidget::processedMaxUrls( const std::size_t count )
|
||||
{
|
||||
ui->urlCount->setText( QString( "URLs: %L1" ).arg( count ) );
|
||||
ui->urlCount->setText( QString( "URL mappings: %L1" ).arg( count ) );
|
||||
ui->progressBar->setMaximum( static_cast< int >( count ) );
|
||||
m_max_urls = count;
|
||||
}
|
||||
|
||||
void UrlServiceWidget::processedUrls( const std::size_t count )
|
||||
{
|
||||
ui->urlCount->setText( QString( "URLs: %L1 (%L2 processed)" ).arg( m_max_urls ).arg( count ) );
|
||||
ui->urlCount->setText( QString( "URL mappings: %L1 (%L2 processed)" ).arg( m_max_urls ).arg( count ) );
|
||||
ui->progressBar->setValue( static_cast< int >( count ) );
|
||||
}
|
||||
@@ -56,7 +56,8 @@
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Name: Record URLs</string>
|
||||
<string>Name: Record URLs
|
||||
Type: URLs</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
|
||||
@@ -57,13 +57,20 @@ void UrlServiceWorker::process()
|
||||
|
||||
const auto mapped_ids { m_importer->mapHydrusRecords( hashes ) };
|
||||
|
||||
emit statusMessage( "Adding URLs to records" );
|
||||
|
||||
std::vector< QFuture< void > > futures {};
|
||||
|
||||
for ( const auto& [ hash_id, idhan_id ] : mapped_ids )
|
||||
{
|
||||
auto urls { current_urls[ hash_id ] };
|
||||
auto future { client.addUrls( idhan_id, urls ) };
|
||||
// futures.emplace_back( client.addUrls( idhan_id, urls ) );
|
||||
future.waitForFinished();
|
||||
}
|
||||
|
||||
for ( auto& future : futures ) future.waitForFinished();
|
||||
|
||||
current_urls.clear();
|
||||
emit processedUrls( url_counter );
|
||||
};
|
||||
@@ -82,7 +89,11 @@ void UrlServiceWorker::process()
|
||||
}
|
||||
|
||||
url_counter += urls.size();
|
||||
current_urls.emplace( hash_id, std::move( urls ) );
|
||||
|
||||
if ( auto itter = current_urls.find( hash_id ); itter != current_urls.end() )
|
||||
itter->second.insert( itter->second.end(), urls.begin(), urls.end() );
|
||||
else
|
||||
current_urls.emplace( hash_id, std::move( urls ) );
|
||||
|
||||
if ( url_counter % 500 == 0 ) flushUrls();
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@ list(APPEND HYDRUS_CONSTANTS "SORT_FILES_BY_FILESIZE" "SORT_FILES_BY_DURATION" "
|
||||
list(APPEND HYDRUS_CONSTANTS "SORT_ASC" "SORT_DESC")
|
||||
list(APPEND HYDRUS_CONSTANTS "PAGE_TYPE_DUMPER" "PAGE_TYPE_IMPORT_MULTIPLE_GALLERY" "PAGE_TYPE_IMPORT_SIMPLE_DOWNLOADER" "PAGE_TYPE_IMPORT_HDD" "PAGE_TYPE_IMPORT_WATCHER" "PAGE_TYPE_PETITIONS" "PAGE_TYPE_QUERY" "PAGE_TYPE_IMPORT_URLS" "PAGE_TYPE_DUPLICATE_FILTER" "PAGE_TYPE_IMPORT_MULTIPLE_WATCHER" "PAGE_TYPE_PAGE_OF_PAGES")
|
||||
list(APPEND HYDRUS_CONSTANTS "PAGE_STATE_NORMAL")
|
||||
list(APPEND HYDRUS_CONSTANTS "GENERAL_APPLICATION" "GENERAL_AUDIO" "GENERAL_IMAGE" "GENERAL_VIDEO" "GENERAL_ANIMATION" "GENERAL_APPLICATION_ARCHIVE" "GENERAL_IMAGE_PROJECT")
|
||||
#list(APPEND HYDRUS_CONSTANTS "GENERAL_APPLICATION" "GENERAL_AUDIO" "GENERAL_IMAGE" "GENERAL_VIDEO" "GENERAL_ANIMATION" "GENERAL_APPLICATION_ARCHIVE" "GENERAL_IMAGE_PROJECT")
|
||||
list(APPEND HYDRUS_CONSTANTS "APPLICATION_HYDRUS_CLIENT_COLLECTION" "IMAGE_JPEG" "IMAGE_PNG" "ANIMATION_GIF" "IMAGE_BMP" "APPLICATION_FLASH" "APPLICATION_YAML" "IMAGE_ICON" "TEXT_HTML" "VIDEO_FLV" "APPLICATION_PDF" "APPLICATION_ZIP" "APPLICATION_HYDRUS_ENCRYPTED_ZIP" "AUDIO_MP3" "VIDEO_MP4" "AUDIO_OGG" "AUDIO_FLAC" "AUDIO_WMA" "VIDEO_WMV" "UNDETERMINED_WM" "VIDEO_MKV" "VIDEO_WEBM" "APPLICATION_JSON" "ANIMATION_APNG" "UNDETERMINED_PNG" "VIDEO_MPEG" "VIDEO_MOV" "VIDEO_AVI" "APPLICATION_HYDRUS_UPDATE_DEFINITIONS" "APPLICATION_HYDRUS_UPDATE_CONTENT" "TEXT_PLAIN" "APPLICATION_RAR" "APPLICATION_7Z" "IMAGE_WEBP" "IMAGE_TIFF" "APPLICATION_PSD" "AUDIO_M4A" "VIDEO_REALMEDIA" "AUDIO_REALMEDIA" "AUDIO_TRUEAUDIO" "GENERAL_AUDIO" "GENERAL_IMAGE" "GENERAL_VIDEO" "GENERAL_APPLICATION" "GENERAL_ANIMATION" "APPLICATION_CLIP" "AUDIO_WAVE" "VIDEO_OGV" "AUDIO_MKV" "AUDIO_MP4" "UNDETERMINED_MP4" "APPLICATION_CBOR" "APPLICATION_WINDOWS_EXE" "AUDIO_WAVPACK" "APPLICATION_SAI2" "APPLICATION_KRITA" "IMAGE_SVG" "APPLICATION_XCF" "APPLICATION_GZIP" "GENERAL_APPLICATION_ARCHIVE" "GENERAL_IMAGE_PROJECT" "IMAGE_HEIF" "IMAGE_HEIF_SEQUENCE" "IMAGE_HEIC" "IMAGE_HEIC_SEQUENCE" "IMAGE_AVIF" "IMAGE_AVIF_SEQUENCE" "UNDETERMINED_GIF" "IMAGE_GIF" "APPLICATION_PROCREATE" "IMAGE_QOI" "APPLICATION_EPUB" "APPLICATION_DJVU" "APPLICATION_CBZ" "ANIMATION_UGOIRA" "APPLICATION_RTF" "APPLICATION_DOCX" "APPLICATION_XLSX" "APPLICATION_PPTX" "UNDETERMINED_OLE" "APPLICATION_DOC" "APPLICATION_XLS" "APPLICATION_PPT" "ANIMATION_WEBP" "UNDETERMINED_WEBP" "IMAGE_JXL" "APPLICATION_OCTET_STREAM" "APPLICATION_UNKNOWN")
|
||||
|
||||
# OUT_TARGET = file to write too
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "hydrus/HydrusConstants_gen.hpp"
|
||||
#include "mime_type_map.hpp"
|
||||
|
||||
namespace idhan::hydrus::hy_constants
|
||||
{
|
||||
@@ -14,27 +15,11 @@ enum ServiceTypes
|
||||
TAG_SERVICE = gen_constants::LOCAL_TAG
|
||||
};
|
||||
|
||||
inline std::uint16_t simpleToHyType( SimpleMimeType type )
|
||||
inline std::uint16_t mimeToHyType( const std::string& mime_name )
|
||||
{
|
||||
switch ( type )
|
||||
{
|
||||
default:
|
||||
[[fallthrough]];
|
||||
case SimpleMimeType::NONE:
|
||||
[[fallthrough]];
|
||||
case SimpleMimeType::IMAGE:
|
||||
return gen_constants::GENERAL_IMAGE;
|
||||
case SimpleMimeType::VIDEO:
|
||||
return gen_constants::GENERAL_VIDEO;
|
||||
case SimpleMimeType::ANIMATION:
|
||||
return gen_constants::GENERAL_ANIMATION;
|
||||
case SimpleMimeType::AUDIO:
|
||||
return gen_constants::GENERAL_AUDIO;
|
||||
case SimpleMimeType::ARCHIVE:
|
||||
return gen_constants::GENERAL_APPLICATION_ARCHIVE;
|
||||
case SimpleMimeType::IMAGE_PROJECT:
|
||||
return gen_constants::GENERAL_IMAGE_PROJECT;
|
||||
}
|
||||
if ( auto itter = hy_type_mime.find( mime_name ); itter != hy_type_mime.end() ) return itter->second;
|
||||
|
||||
return gen_constants::GENERAL_IMAGE;
|
||||
}
|
||||
|
||||
} // namespace idhan::hydrus::hy_constants
|
||||
102
IDHAN/include/mime_type_map.hpp
Normal file
102
IDHAN/include/mime_type_map.hpp
Normal file
@@ -0,0 +1,102 @@
|
||||
//
|
||||
// Created by kj16609 on 11/10/25.
|
||||
//
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
#include "hydrus/HydrusConstants_gen.hpp"
|
||||
|
||||
static const std::unordered_map< std::string_view, int > hy_type_mime {
|
||||
{ "unknown", idhan::hydrus::gen_constants::APPLICATION_HYDRUS_CLIENT_COLLECTION },
|
||||
{ "image/jpeg", idhan::hydrus::gen_constants::IMAGE_JPEG },
|
||||
{ "image/png", idhan::hydrus::gen_constants::IMAGE_PNG },
|
||||
{ "image/gif", idhan::hydrus::gen_constants::ANIMATION_GIF },
|
||||
{ "image/bmp", idhan::hydrus::gen_constants::IMAGE_BMP },
|
||||
{ "application/x-shockwave-flash", idhan::hydrus::gen_constants::APPLICATION_FLASH },
|
||||
{ "application/yaml", idhan::hydrus::gen_constants::APPLICATION_YAML },
|
||||
{ "image/x-icon", idhan::hydrus::gen_constants::IMAGE_ICON },
|
||||
{ "text/html", idhan::hydrus::gen_constants::TEXT_HTML },
|
||||
{ "video/x-flv", idhan::hydrus::gen_constants::VIDEO_FLV },
|
||||
{ "application/pdf", idhan::hydrus::gen_constants::APPLICATION_PDF },
|
||||
{ "application/zip", idhan::hydrus::gen_constants::APPLICATION_ZIP },
|
||||
{ "unknown", idhan::hydrus::gen_constants::APPLICATION_HYDRUS_ENCRYPTED_ZIP },
|
||||
{ "audio/mpeg", idhan::hydrus::gen_constants::AUDIO_MP3 },
|
||||
{ "video/mp4", idhan::hydrus::gen_constants::VIDEO_MP4 },
|
||||
{ "audio/ogg", idhan::hydrus::gen_constants::AUDIO_OGG },
|
||||
{ "audio/flac", idhan::hydrus::gen_constants::AUDIO_FLAC },
|
||||
{ "audio/x-ms-wma", idhan::hydrus::gen_constants::AUDIO_WMA },
|
||||
{ "video/x-ms-wmv", idhan::hydrus::gen_constants::VIDEO_WMV },
|
||||
{ "unknown", idhan::hydrus::gen_constants::UNDETERMINED_WM },
|
||||
{ "video/x-matroska", idhan::hydrus::gen_constants::VIDEO_MKV },
|
||||
{ "video/webm", idhan::hydrus::gen_constants::VIDEO_WEBM },
|
||||
{ "application/json", idhan::hydrus::gen_constants::APPLICATION_JSON },
|
||||
{ "image/apng", idhan::hydrus::gen_constants::ANIMATION_APNG },
|
||||
{ "unknown", idhan::hydrus::gen_constants::UNDETERMINED_PNG },
|
||||
{ "video/mpeg", idhan::hydrus::gen_constants::VIDEO_MPEG },
|
||||
{ "video/quicktime", idhan::hydrus::gen_constants::VIDEO_MOV },
|
||||
{ "video/x-msvideo", idhan::hydrus::gen_constants::VIDEO_AVI },
|
||||
{ "unknown", idhan::hydrus::gen_constants::APPLICATION_HYDRUS_UPDATE_DEFINITIONS },
|
||||
{ "unknown", idhan::hydrus::gen_constants::APPLICATION_HYDRUS_UPDATE_CONTENT },
|
||||
{ "text/plain", idhan::hydrus::gen_constants::TEXT_PLAIN },
|
||||
{ "application/x-rar-compressed", idhan::hydrus::gen_constants::APPLICATION_RAR },
|
||||
{ "application/x-7z-compressed", idhan::hydrus::gen_constants::APPLICATION_7Z },
|
||||
{ "image/webp", idhan::hydrus::gen_constants::IMAGE_WEBP },
|
||||
{ "image/tiff", idhan::hydrus::gen_constants::IMAGE_TIFF },
|
||||
{ "application/psd", idhan::hydrus::gen_constants::APPLICATION_PSD },
|
||||
{ "audio/mp4", idhan::hydrus::gen_constants::AUDIO_M4A },
|
||||
{ "video/x-pn-realvideo", idhan::hydrus::gen_constants::VIDEO_REALMEDIA },
|
||||
{ "audio/x-pn-realaudio", idhan::hydrus::gen_constants::AUDIO_REALMEDIA },
|
||||
{ "audio/x-tta", idhan::hydrus::gen_constants::AUDIO_TRUEAUDIO },
|
||||
{ "audio/*", idhan::hydrus::gen_constants::GENERAL_AUDIO },
|
||||
{ "image/*", idhan::hydrus::gen_constants::GENERAL_IMAGE },
|
||||
{ "video/*", idhan::hydrus::gen_constants::GENERAL_VIDEO },
|
||||
{ "application/*", idhan::hydrus::gen_constants::GENERAL_APPLICATION },
|
||||
{ "unknown", idhan::hydrus::gen_constants::GENERAL_ANIMATION },
|
||||
{ "unknown", idhan::hydrus::gen_constants::APPLICATION_CLIP },
|
||||
{ "audio/x-wav", idhan::hydrus::gen_constants::AUDIO_WAVE },
|
||||
{ "video/ogg", idhan::hydrus::gen_constants::VIDEO_OGV },
|
||||
{ "audio/x-matroska", idhan::hydrus::gen_constants::AUDIO_MKV },
|
||||
{ "audio/mp4", idhan::hydrus::gen_constants::AUDIO_MP4 },
|
||||
{ "unknown", idhan::hydrus::gen_constants::UNDETERMINED_MP4 },
|
||||
{ "application/cbor", idhan::hydrus::gen_constants::APPLICATION_CBOR },
|
||||
{ "application/x-msdownload", idhan::hydrus::gen_constants::APPLICATION_WINDOWS_EXE },
|
||||
{ "audio/x-wavpack", idhan::hydrus::gen_constants::AUDIO_WAVPACK },
|
||||
{ "unknown", idhan::hydrus::gen_constants::APPLICATION_SAI2 },
|
||||
{ "application/x-krita", idhan::hydrus::gen_constants::APPLICATION_KRITA },
|
||||
{ "image/svg+xml", idhan::hydrus::gen_constants::IMAGE_SVG },
|
||||
{ "image/x-xcf", idhan::hydrus::gen_constants::APPLICATION_XCF },
|
||||
{ "application/gzip", idhan::hydrus::gen_constants::APPLICATION_GZIP },
|
||||
{ "application/x-archive", idhan::hydrus::gen_constants::GENERAL_APPLICATION_ARCHIVE },
|
||||
{ "unknown", idhan::hydrus::gen_constants::GENERAL_IMAGE_PROJECT },
|
||||
{ "image/heif", idhan::hydrus::gen_constants::IMAGE_HEIF },
|
||||
{ "image/heif-sequence", idhan::hydrus::gen_constants::IMAGE_HEIF_SEQUENCE },
|
||||
{ "image/heic", idhan::hydrus::gen_constants::IMAGE_HEIC },
|
||||
{ "image/heic-sequence", idhan::hydrus::gen_constants::IMAGE_HEIC_SEQUENCE },
|
||||
{ "image/avif", idhan::hydrus::gen_constants::IMAGE_AVIF },
|
||||
{ "image/avif-sequence", idhan::hydrus::gen_constants::IMAGE_AVIF_SEQUENCE },
|
||||
{ "unknown", idhan::hydrus::gen_constants::UNDETERMINED_GIF },
|
||||
{ "image/gif", idhan::hydrus::gen_constants::IMAGE_GIF },
|
||||
{ "unknown", idhan::hydrus::gen_constants::APPLICATION_PROCREATE },
|
||||
{ "image/qoi", idhan::hydrus::gen_constants::IMAGE_QOI },
|
||||
{ "application/epub+zip", idhan::hydrus::gen_constants::APPLICATION_EPUB },
|
||||
{ "image/vnd.djvu", idhan::hydrus::gen_constants::APPLICATION_DJVU },
|
||||
{ "application/vnd.comicbook+zip", idhan::hydrus::gen_constants::APPLICATION_CBZ },
|
||||
{ "unknown", idhan::hydrus::gen_constants::ANIMATION_UGOIRA },
|
||||
{ "application/rtf", idhan::hydrus::gen_constants::APPLICATION_RTF },
|
||||
{ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
idhan::hydrus::gen_constants::APPLICATION_DOCX },
|
||||
{ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
idhan::hydrus::gen_constants::APPLICATION_XLSX },
|
||||
{ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
idhan::hydrus::gen_constants::APPLICATION_PPTX },
|
||||
{ "unknown", idhan::hydrus::gen_constants::UNDETERMINED_OLE },
|
||||
{ "application/msword", idhan::hydrus::gen_constants::APPLICATION_DOC },
|
||||
{ "application/vnd.ms-excel", idhan::hydrus::gen_constants::APPLICATION_XLS },
|
||||
{ "application/vnd.ms-powerpoint", idhan::hydrus::gen_constants::APPLICATION_PPT },
|
||||
{ "image/webp", idhan::hydrus::gen_constants::ANIMATION_WEBP },
|
||||
{ "unknown", idhan::hydrus::gen_constants::UNDETERMINED_WEBP },
|
||||
{ "image/jxl", idhan::hydrus::gen_constants::IMAGE_JXL },
|
||||
{ "application/octet-stream", idhan::hydrus::gen_constants::APPLICATION_OCTET_STREAM },
|
||||
{ "unknown", idhan::hydrus::gen_constants::APPLICATION_UNKNOWN }
|
||||
};
|
||||
11
IDHANMigration/src/135-video_metadata.sql
Normal file
11
IDHANMigration/src/135-video_metadata.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE video_metadata
|
||||
(
|
||||
record_id INTEGER REFERENCES records (record_id),
|
||||
duration FLOAT NOT NULL,
|
||||
framerate FLOAT NOT NULL,
|
||||
width INTEGER NOT NULL,
|
||||
height INTEGER NOT NULL,
|
||||
bitrate INTEGER NOT NULL,
|
||||
has_audio BOOLEAN NOT NULL,
|
||||
UNIQUE (record_id)
|
||||
);
|
||||
9
IDHANMigration/src/140-image_project_metadata.sql
Normal file
9
IDHANMigration/src/140-image_project_metadata.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE image_project_metadata
|
||||
(
|
||||
record_id INTEGER REFERENCES records (record_id),
|
||||
width INTEGER NOT NULL,
|
||||
height INTEGER NOT NULL,
|
||||
channels SMALLINT NOT NULL,
|
||||
layers SMALLINT NOT NULL,
|
||||
UNIQUE (record_id)
|
||||
);
|
||||
@@ -4,11 +4,27 @@ target_link_libraries(IDHANModules PUBLIC IDHAN)
|
||||
include(ExternalProject)
|
||||
|
||||
find_package(PkgConfig REQUIRED)
|
||||
|
||||
# VideoMetadata & VideoThumbnailer
|
||||
pkg_check_modules(FFMPEG REQUIRED IMPORTED_TARGET
|
||||
libavcodec
|
||||
libavformat
|
||||
libavutil
|
||||
libswscale)
|
||||
|
||||
# ImageVipsMetadata & ImageVipsThumbnailer
|
||||
pkg_check_modules(VIPS REQUIRED vips)
|
||||
|
||||
AddFGLModule(IDHANPremadeModules ${CMAKE_CURRENT_SOURCE_DIR}/premade)
|
||||
target_link_libraries(IDHANPremadeModules PUBLIC IDHANModules IDHAN ${VIPS_LIBRARIES} spdlog)
|
||||
target_link_libraries(IDHANPremadeModules PUBLIC IDHANModules IDHAN spdlog)
|
||||
|
||||
# Vips
|
||||
target_link_libraries(IDHANPremadeModules PUBLIC ${VIPS_LIBRARIES})
|
||||
target_include_directories(IDHANPremadeModules PRIVATE ${VIPS_INCLUDE_DIRS})
|
||||
#target_link_libraries(IDHANPremadeModules PRIVATE PkgConfig::VIPS)
|
||||
|
||||
# Video (FFMPEG)
|
||||
target_link_libraries(IDHANPremadeModules PRIVATE PkgConfig::FFMPEG)
|
||||
|
||||
set_target_properties(IDHANPremadeModules PROPERTIES
|
||||
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin/modules"
|
||||
|
||||
@@ -12,19 +12,40 @@
|
||||
|
||||
namespace idhan
|
||||
{
|
||||
|
||||
struct MetadataInfoImage
|
||||
{
|
||||
int width;
|
||||
int height;
|
||||
std::uint8_t channels;
|
||||
int width { 0 };
|
||||
int height { 0 };
|
||||
std::uint8_t channels { 0 };
|
||||
};
|
||||
|
||||
struct MetadataInfoAnimation
|
||||
{};
|
||||
|
||||
struct MetadataInfoImageProject
|
||||
{
|
||||
MetadataInfoImage image_info {};
|
||||
std::uint8_t layers { 0 };
|
||||
};
|
||||
|
||||
struct MetadataInfoVideo
|
||||
{
|
||||
bool m_has_audio { false };
|
||||
int m_width { 0 };
|
||||
int m_height { 0 };
|
||||
int m_bitrate { 0 };
|
||||
double m_duration { 0.0 };
|
||||
double m_fps { 0.0 };
|
||||
};
|
||||
|
||||
using MetadataVariant = std::
|
||||
variant< std::monostate, MetadataInfoImage, MetadataInfoVideo, MetadataInfoImageProject, MetadataInfoAnimation >;
|
||||
|
||||
struct MetadataInfo
|
||||
{
|
||||
std::variant< std::monostate, MetadataInfoImage, MetadataInfoAnimation > m_metadata {};
|
||||
MetadataVariant m_metadata {};
|
||||
|
||||
std::string m_extra {};
|
||||
SimpleMimeType m_simple_type { SimpleMimeType::NONE };
|
||||
};
|
||||
@@ -40,7 +61,7 @@ class FGL_EXPORT MetadataModuleI : public ModuleBase
|
||||
virtual std::vector< std::string_view > handleableMimes() = 0;
|
||||
|
||||
virtual std::expected< MetadataInfo, ModuleError > parseFile(
|
||||
void* data,
|
||||
const void* data,
|
||||
std::size_t length,
|
||||
std::string mime_name ) = 0;
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ class FGL_EXPORT ThumbnailerModuleI : public ModuleBase
|
||||
virtual std::vector< std::string_view > handleableMimes() = 0;
|
||||
|
||||
virtual std::expected< ThumbnailInfo, ModuleError > createThumbnail(
|
||||
void* data,
|
||||
const void* data,
|
||||
std::size_t length,
|
||||
std::size_t width,
|
||||
std::size_t height,
|
||||
|
||||
128
IDHANModules/premade/FFMPEGMetadata.cpp
Normal file
128
IDHANModules/premade/FFMPEGMetadata.cpp
Normal file
@@ -0,0 +1,128 @@
|
||||
//
|
||||
// Created by kj16609 on 11/12/25.
|
||||
//
|
||||
#include "FFMPEGMetadata.hpp"
|
||||
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
|
||||
#include "ffmpeg.hpp"
|
||||
|
||||
extern "C" {
|
||||
#include <libavcodec/avcodec.h>
|
||||
#include <libavformat/avformat.h>
|
||||
#include <libswscale/swscale.h>
|
||||
}
|
||||
|
||||
std::string_view FFMPEGMetadata::name()
|
||||
{
|
||||
return "Video Metadata Module";
|
||||
}
|
||||
|
||||
idhan::ModuleVersion FFMPEGMetadata::version()
|
||||
{
|
||||
return { .m_major = 1, .m_minor = 0, .m_patch = 0 };
|
||||
}
|
||||
|
||||
std::vector< std::string_view > FFMPEGMetadata::handleableMimes()
|
||||
{
|
||||
return ffmpeg_handleable_mimes;
|
||||
}
|
||||
|
||||
std::expected< idhan::MetadataInfo, idhan::ModuleError > FFMPEGMetadata::parseFile(
|
||||
const void* data,
|
||||
std::size_t length,
|
||||
std::string mime_name )
|
||||
{
|
||||
idhan::MetadataInfo base_info {};
|
||||
base_info.m_simple_type = idhan::SimpleMimeType::VIDEO;
|
||||
idhan::MetadataInfoVideo video_metadata {};
|
||||
|
||||
OpaqueInfo opaque_info { .m_data = std::string_view( static_cast< const char* >( data ), length ), .m_cursor = 0 };
|
||||
|
||||
constexpr auto BUFFER_SIZE { 4096 };
|
||||
// std::array< std::byte, BUFFER_SIZE > buffer {};
|
||||
std::byte* buffer_ptr { new std::byte[ BUFFER_SIZE ] };
|
||||
|
||||
const std::shared_ptr< AVIOContext > avio_context(
|
||||
avio_alloc_context(
|
||||
reinterpret_cast< unsigned char* >( buffer_ptr ),
|
||||
BUFFER_SIZE,
|
||||
0,
|
||||
&opaque_info,
|
||||
&readFunction,
|
||||
nullptr,
|
||||
seekFunction ),
|
||||
&av_free );
|
||||
|
||||
const auto format_context_p =
|
||||
std::shared_ptr< AVFormatContext >( avformat_alloc_context(), &avformat_free_context );
|
||||
auto format_context { format_context_p.get() };
|
||||
|
||||
format_context->pb = avio_context.get();
|
||||
format_context->flags |= AVFMT_FLAG_CUSTOM_IO;
|
||||
|
||||
if ( avformat_open_input( &format_context, "", nullptr, nullptr ) < 0 )
|
||||
{
|
||||
return std::unexpected( idhan::ModuleError( "Failed to open file" ) );
|
||||
}
|
||||
|
||||
if ( avformat_find_stream_info( format_context, nullptr ) < 0 )
|
||||
{
|
||||
return std::unexpected( idhan::ModuleError( "Failed to find stream info" ) );
|
||||
}
|
||||
|
||||
bool has_video { false };
|
||||
bool has_audio { false };
|
||||
|
||||
for ( unsigned int i = 0; i < format_context->nb_streams; ++i )
|
||||
{
|
||||
AVStream* stream = format_context->streams[ i ];
|
||||
AVCodecParameters* codecpar = stream->codecpar;
|
||||
|
||||
if ( codecpar->codec_type == AVMEDIA_TYPE_VIDEO && !has_video )
|
||||
{
|
||||
has_video = true;
|
||||
video_metadata.m_width = codecpar->width;
|
||||
video_metadata.m_height = codecpar->height;
|
||||
|
||||
// Calculate bitrate (prefer codec bitrate, fall back to container bitrate)
|
||||
if ( codecpar->bit_rate > 0 )
|
||||
{
|
||||
video_metadata.m_bitrate = codecpar->bit_rate;
|
||||
}
|
||||
else if ( format_context->bit_rate > 0 )
|
||||
{
|
||||
// Fall back to container bitrate if no stream-specific bitrate
|
||||
video_metadata.m_bitrate = format_context->bit_rate;
|
||||
}
|
||||
|
||||
// Get duration (in seconds)
|
||||
if ( stream->duration != AV_NOPTS_VALUE )
|
||||
{
|
||||
video_metadata.m_duration = static_cast< double >( stream->duration ) * av_q2d( stream->time_base );
|
||||
}
|
||||
else if ( format_context->duration != AV_NOPTS_VALUE )
|
||||
{
|
||||
video_metadata.m_duration = static_cast< double >( format_context->duration ) / AV_TIME_BASE;
|
||||
}
|
||||
|
||||
// Get frame rate
|
||||
if ( stream->avg_frame_rate.den > 0 )
|
||||
{
|
||||
video_metadata.m_fps = av_q2d( stream->avg_frame_rate );
|
||||
}
|
||||
}
|
||||
else if ( codecpar->codec_type == AVMEDIA_TYPE_AUDIO )
|
||||
{
|
||||
has_audio = true;
|
||||
}
|
||||
}
|
||||
|
||||
video_metadata.m_has_audio = has_audio;
|
||||
|
||||
base_info.m_metadata = video_metadata;
|
||||
|
||||
return base_info;
|
||||
}
|
||||
22
IDHANModules/premade/FFMPEGMetadata.hpp
Normal file
22
IDHANModules/premade/FFMPEGMetadata.hpp
Normal file
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// Created by kj16609 on 11/12/25.
|
||||
//
|
||||
#pragma once
|
||||
#include "MetadataModule.hpp"
|
||||
|
||||
|
||||
class FFMPEGMetadata final : public idhan::MetadataModuleI
|
||||
{
|
||||
public:
|
||||
|
||||
std::string_view name() override;
|
||||
|
||||
idhan::ModuleVersion version() override;
|
||||
|
||||
std::vector< std::string_view > handleableMimes() override;
|
||||
|
||||
std::expected< idhan::MetadataInfo, idhan::ModuleError > parseFile(
|
||||
const void* data,
|
||||
std::size_t length,
|
||||
std::string mime_name ) override;
|
||||
};
|
||||
346
IDHANModules/premade/FFMPEGThumbnailer.cpp
Normal file
346
IDHANModules/premade/FFMPEGThumbnailer.cpp
Normal file
@@ -0,0 +1,346 @@
|
||||
//
|
||||
// Created by kj16609 on 11/13/25.
|
||||
//
|
||||
#include "FFMPEGThumbnailer.hpp"
|
||||
|
||||
#include <vips/vips8>
|
||||
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include "ffmpeg.hpp"
|
||||
|
||||
extern "C" {
|
||||
#include <libavcodec/avcodec.h>
|
||||
#include <libavformat/avformat.h>
|
||||
#include <libavformat/avio.h>
|
||||
#include <libavutil/imgutils.h>
|
||||
#include <libavutil/mem.h>
|
||||
#include <libswscale/swscale.h>
|
||||
}
|
||||
|
||||
// Custom deleters for FFmpeg types
|
||||
struct AVFormatContextDeleter
|
||||
{
|
||||
void operator()( AVFormatContext* ctx ) const
|
||||
{
|
||||
if ( ctx )
|
||||
{
|
||||
avformat_close_input( &ctx );
|
||||
avformat_free_context( ctx );
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
struct AVCodecContextDeleter
|
||||
{
|
||||
void operator()( AVCodecContext* ctx ) const
|
||||
{
|
||||
if ( ctx )
|
||||
{
|
||||
avcodec_free_context( &ctx );
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
struct AVPacketDeleter
|
||||
{
|
||||
void operator()( AVPacket* pkt ) const
|
||||
{
|
||||
if ( pkt )
|
||||
{
|
||||
av_packet_free( &pkt );
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
struct AVFrameDeleter
|
||||
{
|
||||
void operator()( AVFrame* frame ) const
|
||||
{
|
||||
if ( frame )
|
||||
{
|
||||
av_frame_free( &frame );
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
struct SwsContextDeleter
|
||||
{
|
||||
void operator()( SwsContext* ctx ) const
|
||||
{
|
||||
if ( ctx )
|
||||
{
|
||||
sws_freeContext( ctx );
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
struct AVIOContextDeleter
|
||||
{
|
||||
void operator()( AVIOContext* ctx ) const
|
||||
{
|
||||
if ( ctx )
|
||||
{
|
||||
av_free( ctx );
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
struct VipsImageDeleter
|
||||
{
|
||||
void operator()( VipsImage* img ) const
|
||||
{
|
||||
if ( img )
|
||||
{
|
||||
g_object_unref( img );
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
std::string_view FFMPEGThumbnailer::name()
|
||||
{
|
||||
return "FFMPEGThumbnailer";
|
||||
}
|
||||
|
||||
idhan::ModuleVersion FFMPEGThumbnailer::version()
|
||||
{
|
||||
return idhan::ModuleVersion { .m_major = 0, .m_minor = 1, .m_patch = 0 };
|
||||
}
|
||||
|
||||
std::vector< std::string_view > FFMPEGThumbnailer::handleableMimes()
|
||||
{
|
||||
return ffmpeg_handleable_mimes;
|
||||
}
|
||||
|
||||
std::expected< idhan::ThumbnailerModuleI::ThumbnailInfo, idhan::ModuleError > FFMPEGThumbnailer::createThumbnail(
|
||||
const void* data,
|
||||
std::size_t length,
|
||||
std::size_t width,
|
||||
std::size_t height,
|
||||
std::string mime_name )
|
||||
{
|
||||
OpaqueInfo opaque_info { .m_data = std::string_view( static_cast< const char* >( data ), length ), .m_cursor = 0 };
|
||||
|
||||
constexpr auto BUFFER_SIZE { 4096 };
|
||||
std::byte* buffer_ptr { new std::byte[ BUFFER_SIZE ] };
|
||||
|
||||
std::unique_ptr< AVIOContext, AVIOContextDeleter > avio_context( avio_alloc_context(
|
||||
reinterpret_cast< unsigned char* >( buffer_ptr ),
|
||||
BUFFER_SIZE,
|
||||
0,
|
||||
&opaque_info,
|
||||
&readFunction,
|
||||
nullptr,
|
||||
seekFunction ) );
|
||||
|
||||
if ( !avio_context )
|
||||
{
|
||||
return std::unexpected( idhan::ModuleError( "Failed to allocate AVIO context" ) );
|
||||
}
|
||||
|
||||
std::unique_ptr< AVFormatContext, AVFormatContextDeleter > format_context( avformat_alloc_context() );
|
||||
if ( !format_context )
|
||||
{
|
||||
return std::unexpected( idhan::ModuleError( "Failed to allocate format context" ) );
|
||||
}
|
||||
|
||||
format_context->pb = avio_context.get();
|
||||
format_context->flags |= AVFMT_FLAG_CUSTOM_IO;
|
||||
|
||||
AVFormatContext* format_context_raw = format_context.get();
|
||||
if ( avformat_open_input( &format_context_raw, "", nullptr, nullptr ) < 0 )
|
||||
{
|
||||
return std::unexpected( idhan::ModuleError( "Failed to open file" ) );
|
||||
}
|
||||
// avformat_open_input may reallocate the context, update our unique_ptr
|
||||
format_context.release();
|
||||
format_context.reset( format_context_raw );
|
||||
|
||||
if ( avformat_find_stream_info( format_context.get(), nullptr ) < 0 )
|
||||
{
|
||||
return std::unexpected( idhan::ModuleError( "Failed to find stream info" ) );
|
||||
}
|
||||
|
||||
// Find video stream
|
||||
int video_stream_index = -1;
|
||||
AVCodecParameters* codec_params = nullptr;
|
||||
for ( unsigned int i = 0; i < format_context->nb_streams; i++ )
|
||||
{
|
||||
if ( format_context->streams[ i ]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO )
|
||||
{
|
||||
video_stream_index = i;
|
||||
codec_params = format_context->streams[ i ]->codecpar;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ( video_stream_index == -1 )
|
||||
{
|
||||
return std::unexpected( idhan::ModuleError( "No video stream found" ) );
|
||||
}
|
||||
|
||||
// Calculate 10% duration and seek to it
|
||||
const auto duration { av_rescale_q(
|
||||
format_context->duration, AV_TIME_BASE_Q, format_context->streams[ video_stream_index ]->time_base ) };
|
||||
const auto target_timestamp { static_cast< int64_t >( duration * 0.10 ) };
|
||||
|
||||
if ( av_seek_frame( format_context.get(), video_stream_index, target_timestamp, AVSEEK_FLAG_BACKWARD ) < 0 )
|
||||
{
|
||||
return std::unexpected( idhan::ModuleError( "Failed to seek to timestamp" ) );
|
||||
}
|
||||
|
||||
// Find and open codec
|
||||
const AVCodec* codec = avcodec_find_decoder( codec_params->codec_id );
|
||||
if ( !codec )
|
||||
{
|
||||
return std::unexpected( idhan::ModuleError( "Codec not found" ) );
|
||||
}
|
||||
|
||||
std::unique_ptr< AVCodecContext, AVCodecContextDeleter > codec_context( avcodec_alloc_context3( codec ) );
|
||||
if ( !codec_context )
|
||||
{
|
||||
return std::unexpected( idhan::ModuleError( "Failed to allocate codec context" ) );
|
||||
}
|
||||
|
||||
if ( avcodec_parameters_to_context( codec_context.get(), codec_params ) < 0 )
|
||||
{
|
||||
return std::unexpected( idhan::ModuleError( "Failed to copy codec params" ) );
|
||||
}
|
||||
|
||||
if ( avcodec_open2( codec_context.get(), codec, nullptr ) < 0 )
|
||||
{
|
||||
return std::unexpected( idhan::ModuleError( "Failed to open codec" ) );
|
||||
}
|
||||
|
||||
// Read frames until we get a valid video frame
|
||||
const std::unique_ptr< AVPacket, AVPacketDeleter > packet( av_packet_alloc() );
|
||||
const std::unique_ptr< AVFrame, AVFrameDeleter > frame( av_frame_alloc() );
|
||||
|
||||
if ( !packet || !frame )
|
||||
{
|
||||
return std::unexpected( idhan::ModuleError( "Failed to allocate packet or frame" ) );
|
||||
}
|
||||
|
||||
bool frame_decoded = false;
|
||||
|
||||
while ( av_read_frame( format_context.get(), packet.get() ) >= 0 )
|
||||
{
|
||||
if ( packet->stream_index == video_stream_index )
|
||||
{
|
||||
if ( avcodec_send_packet( codec_context.get(), packet.get() ) >= 0 )
|
||||
{
|
||||
if ( avcodec_receive_frame( codec_context.get(), frame.get() ) >= 0 )
|
||||
{
|
||||
frame_decoded = true;
|
||||
av_packet_unref( packet.get() );
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
av_packet_unref( packet.get() );
|
||||
}
|
||||
|
||||
if ( !frame_decoded )
|
||||
{
|
||||
return std::unexpected( idhan::ModuleError( "Failed to decode frame" ) );
|
||||
}
|
||||
|
||||
// Convert frame to RGB24 using swscale
|
||||
std::unique_ptr< SwsContext, SwsContextDeleter > sws_context( sws_getContext(
|
||||
codec_context->width,
|
||||
codec_context->height,
|
||||
codec_context->pix_fmt,
|
||||
codec_context->width,
|
||||
codec_context->height,
|
||||
AV_PIX_FMT_RGB24,
|
||||
SWS_BILINEAR,
|
||||
nullptr,
|
||||
nullptr,
|
||||
nullptr ) );
|
||||
|
||||
if ( !sws_context )
|
||||
{
|
||||
return std::unexpected( idhan::ModuleError( "Failed to create swscale context" ) );
|
||||
}
|
||||
|
||||
std::unique_ptr< AVFrame, AVFrameDeleter > rgb_frame( av_frame_alloc() );
|
||||
if ( !rgb_frame )
|
||||
{
|
||||
return std::unexpected( idhan::ModuleError( "Failed to allocate RGB frame" ) );
|
||||
}
|
||||
|
||||
rgb_frame->format = AV_PIX_FMT_RGB24;
|
||||
rgb_frame->width = codec_context->width;
|
||||
rgb_frame->height = codec_context->height;
|
||||
|
||||
if ( av_frame_get_buffer( rgb_frame.get(), 0 ) < 0 )
|
||||
{
|
||||
return std::unexpected( idhan::ModuleError( "Failed to allocate RGB frame buffer" ) );
|
||||
}
|
||||
|
||||
sws_scale(
|
||||
sws_context.get(),
|
||||
frame->data,
|
||||
frame->linesize,
|
||||
0,
|
||||
codec_context->height,
|
||||
rgb_frame->data,
|
||||
rgb_frame->linesize );
|
||||
|
||||
// Create packed buffer without alignment padding
|
||||
const size_t packed_line_size = rgb_frame->width * 3;
|
||||
const size_t packed_size = packed_line_size * rgb_frame->height;
|
||||
std::vector< unsigned char > packed_data( packed_size );
|
||||
|
||||
// Copy data line by line to remove padding
|
||||
for ( int y = 0; y < rgb_frame->height; ++y )
|
||||
{
|
||||
std::memcpy(
|
||||
packed_data.data() + ( y * packed_line_size ),
|
||||
rgb_frame->data[ 0 ] + ( y * rgb_frame->linesize[ 0 ] ),
|
||||
packed_line_size );
|
||||
}
|
||||
|
||||
std::unique_ptr< VipsImage, VipsImageDeleter > image( vips_image_new_from_memory(
|
||||
packed_data.data(), packed_size, rgb_frame->width, rgb_frame->height, 3, VIPS_FORMAT_UCHAR ) );
|
||||
|
||||
if ( !image )
|
||||
{
|
||||
return std::unexpected( idhan::ModuleError { "Failed to create VIPS image" } );
|
||||
}
|
||||
|
||||
const float source_aspect { static_cast< float >( rgb_frame->width ) / static_cast< float >( rgb_frame->height ) };
|
||||
const float target_aspect { static_cast< float >( width ) / static_cast< float >( height ) };
|
||||
|
||||
if ( target_aspect > source_aspect )
|
||||
width = static_cast< std::size_t >( static_cast< float >( height ) * source_aspect );
|
||||
else
|
||||
height = static_cast< std::size_t >( static_cast< float >( width ) / source_aspect );
|
||||
|
||||
VipsImage* resized { nullptr };
|
||||
if ( vips_resize(
|
||||
image.get(),
|
||||
&resized,
|
||||
static_cast< double >( width ) / static_cast< double >( vips_image_get_width( image.get() ) ),
|
||||
nullptr ) )
|
||||
{
|
||||
return std::unexpected( idhan::ModuleError { "Failed to resize image" } );
|
||||
}
|
||||
std::unique_ptr< VipsImage, VipsImageDeleter > resized_image( resized );
|
||||
|
||||
// Encode to PNG
|
||||
void* output_buffer { nullptr };
|
||||
std::size_t output_length { 0 };
|
||||
if ( vips_pngsave_buffer( resized_image.get(), &output_buffer, &output_length, nullptr ) )
|
||||
{
|
||||
return std::unexpected( idhan::ModuleError( "Failed to encode PNG" ) );
|
||||
}
|
||||
|
||||
const std::vector< std::byte > output(
|
||||
static_cast< std::byte* >( output_buffer ), static_cast< std::byte* >( output_buffer ) + output_length );
|
||||
g_free( output_buffer );
|
||||
|
||||
return idhan::ThumbnailerModuleI::ThumbnailInfo { .data = std::move( output ), .width = width, .height = height };
|
||||
}
|
||||
24
IDHANModules/premade/FFMPEGThumbnailer.hpp
Normal file
24
IDHANModules/premade/FFMPEGThumbnailer.hpp
Normal file
@@ -0,0 +1,24 @@
|
||||
//
|
||||
// Created by kj16609 on 11/13/25.
|
||||
//
|
||||
#pragma once
|
||||
|
||||
#include "ThumbnailerModule.hpp"
|
||||
|
||||
class FFMPEGThumbnailer final : public idhan::ThumbnailerModuleI
|
||||
{
|
||||
public:
|
||||
|
||||
std::string_view name() override;
|
||||
|
||||
idhan::ModuleVersion version() override;
|
||||
|
||||
std::vector< std::string_view > handleableMimes() override;
|
||||
|
||||
std::expected< ThumbnailInfo, idhan::ModuleError > createThumbnail(
|
||||
const void* data,
|
||||
std::size_t length,
|
||||
std::size_t width,
|
||||
std::size_t height,
|
||||
std::string mime_name ) override;
|
||||
};
|
||||
@@ -19,14 +19,14 @@ std::vector< std::string_view > ImageVipsMetadata::handleableMimes()
|
||||
}
|
||||
|
||||
std::expected< MetadataInfo, ModuleError > ImageVipsMetadata::parseFile(
|
||||
void* data,
|
||||
const void* data,
|
||||
const std::size_t length,
|
||||
const std::string mime_name )
|
||||
{
|
||||
VipsImage* image;
|
||||
if ( const auto it = VIPS_FUNC_MAP.find( mime_name ); it != VIPS_FUNC_MAP.end() )
|
||||
{
|
||||
if ( it->second( data, length, &image, nullptr ) != 0 )
|
||||
if ( it->second( const_cast< void* >( data ), length, &image, nullptr ) != 0 )
|
||||
{
|
||||
return std::unexpected( ModuleError { "Failed to load image" } );
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ class ImageVipsMetadata final : public idhan::MetadataModuleI
|
||||
|
||||
std::vector< std::string_view > handleableMimes() override;
|
||||
std::expected< idhan::MetadataInfo, idhan::ModuleError > parseFile(
|
||||
void* data,
|
||||
const void* data,
|
||||
std::size_t length,
|
||||
std::string mime_name ) override;
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ std::vector< std::string_view > ImageVipsThumbnailer::handleableMimes()
|
||||
}
|
||||
|
||||
std::expected< ThumbnailerModuleI::ThumbnailInfo, ModuleError > ImageVipsThumbnailer::createThumbnail(
|
||||
void* data,
|
||||
const void* data,
|
||||
const std::size_t length,
|
||||
std::size_t width,
|
||||
std::size_t height,
|
||||
@@ -26,7 +26,7 @@ std::expected< ThumbnailerModuleI::ThumbnailInfo, ModuleError > ImageVipsThumbna
|
||||
VipsImage* image;
|
||||
if ( const auto it = VIPS_FUNC_MAP.find( mime_name ); it != VIPS_FUNC_MAP.end() )
|
||||
{
|
||||
if ( it->second( data, length, &image, nullptr ) != 0 )
|
||||
if ( it->second( const_cast< void* >( data ), length, &image, nullptr ) != 0 )
|
||||
{
|
||||
return std::unexpected( ModuleError { "Failed to load image" } );
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ class ImageVipsThumbnailer : public idhan::ThumbnailerModuleI
|
||||
std::vector< std::string_view > handleableMimes() override;
|
||||
|
||||
std::expected< ThumbnailInfo, idhan::ModuleError > createThumbnail(
|
||||
void* data,
|
||||
const void* data,
|
||||
std::size_t length,
|
||||
std::size_t width,
|
||||
std::size_t height,
|
||||
|
||||
592
IDHANModules/premade/PsdMetadata.cpp
Normal file
592
IDHANModules/premade/PsdMetadata.cpp
Normal file
@@ -0,0 +1,592 @@
|
||||
//
|
||||
// Created by kj16609 on 11/12/25.
|
||||
//
|
||||
#include "PsdMetadata.hpp"
|
||||
|
||||
#include <vips/vips8>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
#include "ModuleBase.hpp"
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
std::uint16_t readUint16BE( const std::uint8_t* data )
|
||||
{
|
||||
return ( static_cast< std::uint16_t >( data[ 0 ] ) << 8 ) | ( static_cast< std::uint16_t >( data[ 1 ] ) << 0 );
|
||||
}
|
||||
|
||||
std::uint32_t readUint32BE( const std::uint8_t* data )
|
||||
{
|
||||
return ( static_cast< std::uint32_t >( data[ 0 ] ) << 24 ) | ( static_cast< std::uint32_t >( data[ 1 ] ) << 16 )
|
||||
| ( static_cast< std::uint32_t >( data[ 2 ] ) << 8 ) | ( static_cast< std::uint32_t >( data[ 3 ] ) << 0 );
|
||||
}
|
||||
|
||||
float readFloat32BE( const std::uint8_t* data )
|
||||
{
|
||||
std::uint32_t bits { readUint32BE( data ) };
|
||||
return *reinterpret_cast< float* >( &bits );
|
||||
}
|
||||
|
||||
struct PSDHeader
|
||||
{
|
||||
std::uint16_t channels;
|
||||
std::uint32_t height;
|
||||
std::uint32_t width;
|
||||
std::uint16_t depth;
|
||||
std::uint16_t colorMode;
|
||||
};
|
||||
|
||||
std::optional< PSDHeader > parsePSDHeader( const std::uint8_t* data, const std::size_t length )
|
||||
{
|
||||
if ( length < 26 ) return std::nullopt;
|
||||
|
||||
if ( memcmp( data, "8BPS", 4 ) != 0 ) return std::nullopt;
|
||||
|
||||
const std::uint16_t version { readUint16BE( data + 4 ) };
|
||||
if ( version != 1 ) return std::nullopt; // TODO: support v2 "large" format
|
||||
|
||||
return { {
|
||||
.channels = readUint16BE( data + 12 ),
|
||||
.height = readUint32BE( data + 14 ),
|
||||
.width = readUint32BE( data + 18 ),
|
||||
.depth = readUint16BE( data + 22 ),
|
||||
.colorMode = readUint16BE( data + 24 ),
|
||||
} };
|
||||
}
|
||||
|
||||
void unpackScanline(
|
||||
const std::uint8_t* buffer,
|
||||
const std::size_t bufferLength,
|
||||
std::uint8_t* output,
|
||||
const std::size_t outputLength )
|
||||
{
|
||||
std::size_t inputIdx { 0 };
|
||||
std::size_t outputIdx { 0 };
|
||||
|
||||
while ( outputIdx < outputLength && inputIdx < bufferLength )
|
||||
{
|
||||
const std::uint8_t headerByte { buffer[ inputIdx++ ] };
|
||||
if ( headerByte > 128 )
|
||||
{
|
||||
const std::uint16_t repeatCount { static_cast< std::uint16_t >( 257 - headerByte ) };
|
||||
if ( inputIdx >= bufferLength ) break;
|
||||
const std::uint8_t value { buffer[ inputIdx++ ] };
|
||||
for ( std::uint16_t r = 0; r < repeatCount && outputIdx < outputLength; ++r ) output[ outputIdx++ ] = value;
|
||||
}
|
||||
else if ( headerByte < 128 )
|
||||
{
|
||||
const std::uint16_t literalLength { static_cast< std::uint16_t >( headerByte + 1 ) };
|
||||
for ( std::uint16_t c = 0; c < literalLength && outputIdx < outputLength && inputIdx < bufferLength; ++c )
|
||||
output[ outputIdx++ ] = buffer[ inputIdx++ ];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::vector< std::uint8_t > unpackRaster(
|
||||
const std::uint8_t* buffer,
|
||||
std::size_t& offset,
|
||||
const std::size_t dataLength,
|
||||
const std::uint32_t width,
|
||||
const std::uint32_t height,
|
||||
const std::uint16_t channels )
|
||||
{
|
||||
const std::size_t scanlineCountsSize { height * channels * 2 };
|
||||
if ( offset + scanlineCountsSize > dataLength )
|
||||
{
|
||||
return {};
|
||||
}
|
||||
|
||||
std::vector< std::uint16_t > scanlineLengths( height * channels );
|
||||
for ( std::size_t i = 0; i < scanlineLengths.size(); ++i )
|
||||
{
|
||||
scanlineLengths[ i ] = readUint16BE( buffer + offset + i * 2 );
|
||||
}
|
||||
offset += scanlineCountsSize;
|
||||
|
||||
const std::size_t planeSize { static_cast< std::size_t >( width ) * height };
|
||||
std::vector< std::uint8_t > planarData( planeSize * channels );
|
||||
|
||||
std::size_t outputOffset { 0 };
|
||||
for ( const std::uint16_t scanlineLength : scanlineLengths )
|
||||
{
|
||||
const std::uint16_t compressedLength { scanlineLength };
|
||||
|
||||
if ( offset + compressedLength > dataLength )
|
||||
{
|
||||
return {};
|
||||
}
|
||||
|
||||
unpackScanline( buffer + offset, compressedLength, planarData.data() + outputOffset, width );
|
||||
|
||||
offset += compressedLength;
|
||||
outputOffset += width;
|
||||
}
|
||||
|
||||
return planarData;
|
||||
}
|
||||
|
||||
std::vector< std::uint8_t > convert16to8bit( const std::vector< std::uint8_t >& buffer, const std::size_t pixelCount )
|
||||
{
|
||||
if ( buffer.size() < pixelCount * 2 )
|
||||
{
|
||||
return {};
|
||||
}
|
||||
std::vector< std::uint8_t > result( pixelCount );
|
||||
for ( std::size_t i = 0; i < pixelCount; ++i )
|
||||
result[ i ] = static_cast< std::uint8_t >( readUint16BE( &buffer[ i * 2 ] ) >> 8 );
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector< std::uint8_t > convert32to8bit( const std::vector< std::uint8_t >& buffer, const std::size_t pixelCount )
|
||||
{
|
||||
if ( buffer.size() < pixelCount * 4 )
|
||||
{
|
||||
return {};
|
||||
}
|
||||
std::vector< std::uint8_t > result( pixelCount );
|
||||
for ( std::size_t i = 0; i < pixelCount; ++i )
|
||||
result[ i ] =
|
||||
static_cast< std::uint8_t >( std::clamp( readFloat32BE( &buffer[ i * 4 ] ), 0.0f, 1.0f ) * 255.0f );
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector< std::uint8_t > convertToTargetDepth(
|
||||
const std::vector< std::uint8_t >& buffer,
|
||||
const std::uint16_t depth,
|
||||
const std::size_t pixelCount )
|
||||
{
|
||||
switch ( depth )
|
||||
{
|
||||
case 8:
|
||||
return buffer;
|
||||
case 16:
|
||||
return convert16to8bit( buffer, pixelCount );
|
||||
case 32:
|
||||
return convert32to8bit( buffer, pixelCount );
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
std::expected< std::vector< std::uint8_t >, idhan::ModuleError > convertCMYKtoInterleavedRGB(
|
||||
const std::basic_string_view< std::uint8_t > cmyk,
|
||||
const std::size_t pixelCount )
|
||||
{
|
||||
std::vector< std::uint8_t > rgb( pixelCount * 3 );
|
||||
for ( std::size_t i = 0; i < pixelCount; ++i )
|
||||
{
|
||||
const std::uint8_t c { cmyk[ ( pixelCount * 0 ) + i ] };
|
||||
const std::uint8_t m { cmyk[ ( pixelCount * 1 ) + i ] };
|
||||
const std::uint8_t y { cmyk[ ( pixelCount * 2 ) + i ] };
|
||||
const std::uint8_t k { cmyk[ ( pixelCount * 3 ) + i ] };
|
||||
// Obviously, this does not take ICC profiles into account, but hopefully it is a good enough first approximation.
|
||||
rgb[ i * 3 + 0 ] = static_cast< std::uint8_t >( ( 255 - c ) * ( 255 - k ) / 255 );
|
||||
rgb[ i * 3 + 1 ] = static_cast< std::uint8_t >( ( 255 - m ) * ( 255 - k ) / 255 );
|
||||
rgb[ i * 3 + 2 ] = static_cast< std::uint8_t >( ( 255 - y ) * ( 255 - k ) / 255 );
|
||||
}
|
||||
return rgb;
|
||||
}
|
||||
|
||||
std::expected< std::vector< std::uint8_t >, idhan::ModuleError > convertGrayscaleToInterleavedRGB(
|
||||
const std::basic_string_view< std::uint8_t > gray,
|
||||
const std::size_t pixelCount )
|
||||
{
|
||||
std::vector< std::uint8_t > rgb( pixelCount * 3 );
|
||||
for ( std::size_t i = 0; i < pixelCount; ++i )
|
||||
{
|
||||
const std::uint8_t value { gray[ i ] };
|
||||
rgb[ i * 3 + 0 ] = value;
|
||||
rgb[ i * 3 + 1 ] = value;
|
||||
rgb[ i * 3 + 2 ] = value;
|
||||
}
|
||||
return rgb;
|
||||
}
|
||||
|
||||
std::expected< std::vector< std::uint8_t >, idhan::ModuleError > convertIndexedToInterleavedRGB(
|
||||
const std::basic_string_view< std::uint8_t > indexed,
|
||||
const std::size_t pixelCount,
|
||||
const std::basic_string_view< std::uint8_t > colorTable )
|
||||
{
|
||||
if ( colorTable.length() < 0x300 )
|
||||
{
|
||||
return std::unexpected( idhan::ModuleError { "Short color table" } );
|
||||
}
|
||||
std::vector< std::uint8_t > rgb( pixelCount * 3 );
|
||||
for ( std::size_t i = 0; i < pixelCount; ++i )
|
||||
{
|
||||
const std::uint8_t value { indexed[ i ] };
|
||||
rgb[ i * 3 + 0 ] = colorTable[ value + 0x000 ];
|
||||
rgb[ i * 3 + 1 ] = colorTable[ value + 0x100 ];
|
||||
rgb[ i * 3 + 2 ] = colorTable[ value + 0x200 ];
|
||||
}
|
||||
return rgb;
|
||||
}
|
||||
|
||||
std::expected< std::vector< std::uint8_t >, idhan::ModuleError > convertPlanarRGBToInterleavedRGB(
|
||||
const std::basic_string_view< std::uint8_t > planarData,
|
||||
const std::size_t pixelCount,
|
||||
const std::uint16_t channels )
|
||||
{
|
||||
std::vector< std::uint8_t > interleaved( pixelCount * 3 );
|
||||
for ( std::size_t c = 0; c < std::min< std::size_t >( channels, 3 ); ++c )
|
||||
{
|
||||
const std::uint8_t* planeData { planarData.data() + ( c * pixelCount ) };
|
||||
for ( std::size_t i = 0; i < pixelCount; ++i )
|
||||
{
|
||||
interleaved[ i * 3 + c ] = planeData[ i ];
|
||||
}
|
||||
}
|
||||
return interleaved;
|
||||
}
|
||||
|
||||
std::expected< std::vector< std::uint8_t >, idhan::ModuleError > convertPlanarToInterleavedRGB(
|
||||
const std::basic_string_view< std::uint8_t > planarData,
|
||||
const std::uint16_t colorMode,
|
||||
const std::uint32_t width,
|
||||
const std::uint32_t height,
|
||||
const std::uint16_t channels,
|
||||
const std::basic_string_view< std::uint8_t > colorTable )
|
||||
{
|
||||
const std::size_t pixelCount { static_cast< std::size_t >( width ) * height };
|
||||
|
||||
switch ( colorMode )
|
||||
{
|
||||
case 1: // Grayscale
|
||||
return convertGrayscaleToInterleavedRGB( planarData, pixelCount );
|
||||
case 2: // Indexed
|
||||
return convertIndexedToInterleavedRGB( planarData, pixelCount, colorTable );
|
||||
case 3: // RGB
|
||||
return convertPlanarRGBToInterleavedRGB( planarData, pixelCount, channels );
|
||||
case 4: // CMYK
|
||||
return convertCMYKtoInterleavedRGB( planarData, pixelCount );
|
||||
default:
|
||||
return std::unexpected( idhan::ModuleError { "Unsupported color mode" } );
|
||||
}
|
||||
}
|
||||
|
||||
std::uint32_t countPSDLayers( const std::uint8_t* data, std::size_t length )
|
||||
{
|
||||
// Start past the file header.
|
||||
std::size_t offset { 26 };
|
||||
|
||||
// Then, skip the color mode data.
|
||||
if ( offset + 4 > length ) return 0;
|
||||
const std::uint32_t colorModeLength { readUint32BE( data + offset ) };
|
||||
offset += 4 + colorModeLength;
|
||||
|
||||
// Next, skip image resources section.
|
||||
if ( offset + 4 > length ) return 0;
|
||||
const std::uint32_t resourcesLength { readUint32BE( data + offset ) };
|
||||
offset += 4 + resourcesLength;
|
||||
|
||||
// Read layer and mask info
|
||||
if ( offset + 4 > length ) return 0;
|
||||
const std::uint32_t layerMaskLength { readUint32BE( data + offset ) };
|
||||
offset += 4;
|
||||
if ( layerMaskLength == 0 ) return 0;
|
||||
const std::size_t layerMaskEnd { offset + layerMaskLength };
|
||||
if ( layerMaskEnd > length ) return 0;
|
||||
|
||||
// Read layer info length
|
||||
if ( offset + 4 > length ) return 0;
|
||||
const std::uint32_t layerInfoLength { readUint32BE( data + offset ) };
|
||||
offset += 4;
|
||||
if ( layerInfoLength == 0 ) return 0;
|
||||
|
||||
// Read layer count
|
||||
if ( offset + 2 > length ) return 0;
|
||||
const std::int16_t rawLayerCount { static_cast< int16_t >( readUint16BE( data + offset ) ) };
|
||||
offset += 2;
|
||||
|
||||
const std::uint16_t layerCount { std::abs< std::uint16_t >( rawLayerCount ) };
|
||||
std::uint32_t realLayerCount = 0;
|
||||
|
||||
// Parse each layer to determine groupType
|
||||
for ( std::uint16_t i = 0; i < layerCount; ++i )
|
||||
{
|
||||
// Skip bounds (top, left, bottom, right)
|
||||
if ( offset + 16 > length ) return realLayerCount;
|
||||
offset += 16;
|
||||
|
||||
// Read channel count
|
||||
if ( offset + 2 > length ) return realLayerCount;
|
||||
const std::uint16_t channelCount { readUint16BE( data + offset ) };
|
||||
offset += 2;
|
||||
|
||||
// Skip channel info
|
||||
if ( offset + channelCount * 6 > length ) return realLayerCount;
|
||||
offset += channelCount * 6;
|
||||
|
||||
// Skip signature, blend mode, opacity, clipping, flags, filler
|
||||
if ( offset + 12 > length ) return realLayerCount;
|
||||
offset += 12;
|
||||
|
||||
// Read extra data length
|
||||
if ( offset + 4 > length ) return realLayerCount;
|
||||
const std::uint32_t extraDataLength { readUint32BE( data + offset ) };
|
||||
offset += 4;
|
||||
|
||||
const std::size_t extraDataEnd { offset + extraDataLength };
|
||||
if ( extraDataEnd > length ) return realLayerCount;
|
||||
|
||||
// Skip mask data length + mask data
|
||||
if ( offset + 4 > length ) return realLayerCount;
|
||||
const std::uint32_t maskLength { readUint32BE( data + offset ) };
|
||||
offset += 4 + maskLength;
|
||||
|
||||
// Skip blending ranges
|
||||
if ( offset + 4 > length ) return realLayerCount;
|
||||
const std::uint32_t blendingRangesLength { readUint32BE( data + offset ) };
|
||||
offset += 4 + blendingRangesLength;
|
||||
|
||||
// Skip layer name
|
||||
if ( offset >= length ) return realLayerCount;
|
||||
const std::uint8_t nameLen { data[ offset ] };
|
||||
offset += 1 + ( ( nameLen + 1 + 3 ) & ~3 ); // Padded to 4 bytes
|
||||
|
||||
// Parse additional layer info to find "lsct" (layer section divider)
|
||||
std::uint32_t groupType { 0 }; // Default to NORMA}
|
||||
|
||||
while ( offset + 12 <= extraDataEnd && offset + 12 <= length )
|
||||
{
|
||||
if ( memcmp( data + offset, "8BIM", 4 ) != 0 && memcmp( data + offset, "8B64", 4 ) != 0 ) break;
|
||||
offset += 4;
|
||||
|
||||
char key[ 5 ] = { 0 };
|
||||
memcpy( key, data + offset, 4 );
|
||||
offset += 4;
|
||||
|
||||
const std::uint32_t dataLength { readUint32BE( data + offset ) };
|
||||
offset += 4;
|
||||
|
||||
const std::size_t dataEnd = offset + dataLength;
|
||||
if ( dataEnd > extraDataEnd || dataEnd > length ) break;
|
||||
|
||||
if ( memcmp( key, "lsct", 4 ) == 0 && dataLength >= 4 )
|
||||
{
|
||||
groupType = readUint32BE( data + offset );
|
||||
}
|
||||
|
||||
offset = dataEnd;
|
||||
}
|
||||
|
||||
// Move to end of extra data
|
||||
offset = extraDataEnd;
|
||||
|
||||
if ( groupType != 3 ) // Ignore layer section dividers.
|
||||
{
|
||||
realLayerCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return realLayerCount;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector< std::string_view > PsdMetadata::handleableMimes()
|
||||
{
|
||||
return { "application/psd" };
|
||||
}
|
||||
|
||||
std::string_view PsdMetadata::name()
|
||||
{
|
||||
return "PSD Metadata Parser";
|
||||
}
|
||||
|
||||
idhan::ModuleVersion PsdMetadata::version()
|
||||
{
|
||||
return { .m_major = 1, .m_minor = 0, .m_patch = 0 };
|
||||
}
|
||||
|
||||
std::expected< idhan::MetadataInfo, idhan::ModuleError > PsdMetadata::parseFile(
|
||||
const void* data,
|
||||
const std::size_t length,
|
||||
[[maybe_unused]] std::string mime_name )
|
||||
{
|
||||
const auto* bytes { static_cast< const std::uint8_t* >( data ) };
|
||||
|
||||
const auto header { parsePSDHeader( bytes, length ) };
|
||||
if ( !header )
|
||||
{
|
||||
return std::unexpected( idhan::ModuleError { "Invalid PSD header" } );
|
||||
}
|
||||
|
||||
idhan::MetadataInfo generic_metadata {};
|
||||
idhan::MetadataInfoImageProject project_metadata {};
|
||||
|
||||
project_metadata.image_info.width = static_cast< int >( header->width );
|
||||
project_metadata.image_info.height = static_cast< int >( header->height );
|
||||
project_metadata.image_info.channels = static_cast< std::uint8_t >( header->channels );
|
||||
project_metadata.layers = countPSDLayers( bytes, length );
|
||||
|
||||
generic_metadata.m_simple_type = idhan::SimpleMimeType::IMAGE_PROJECT;
|
||||
generic_metadata.m_metadata = project_metadata;
|
||||
|
||||
return generic_metadata;
|
||||
}
|
||||
|
||||
std::vector< std::string_view > PsdThumbnailer::handleableMimes()
|
||||
{
|
||||
return { "application/psd" };
|
||||
}
|
||||
|
||||
std::string_view PsdThumbnailer::name()
|
||||
{
|
||||
return "PSD Thumbnailer Parser";
|
||||
}
|
||||
|
||||
idhan::ModuleVersion PsdThumbnailer::version()
|
||||
{
|
||||
return { .m_major = 1, .m_minor = 0, .m_patch = 0 };
|
||||
}
|
||||
|
||||
std::expected< idhan::ThumbnailerModuleI::ThumbnailInfo, idhan::ModuleError > PsdThumbnailer::createThumbnail(
|
||||
const void* data,
|
||||
const std::size_t length,
|
||||
std::size_t width,
|
||||
std::size_t height,
|
||||
[[maybe_unused]] std::string mime_name )
|
||||
{
|
||||
const auto bytes { static_cast< const std::uint8_t* >( data ) };
|
||||
|
||||
const auto header { parsePSDHeader( bytes, length ) };
|
||||
if ( !header )
|
||||
{
|
||||
return std::unexpected( idhan::ModuleError { "Invalid PSD header" } );
|
||||
}
|
||||
|
||||
if ( header->depth != 8 && header->depth != 16 && header->depth != 32 )
|
||||
{
|
||||
return std::unexpected( idhan::ModuleError { "Unsupported bit depth" } );
|
||||
}
|
||||
|
||||
std::size_t offset { 26 };
|
||||
|
||||
if ( offset + 4 > length ) return std::unexpected( idhan::ModuleError { "Truncated file" } );
|
||||
const std::uint32_t colorModeLength { readUint32BE( bytes + offset ) };
|
||||
offset += 4;
|
||||
|
||||
if ( offset + colorModeLength > length ) return std::unexpected( idhan::ModuleError { "Truncated file" } );
|
||||
const std::basic_string_view colorTable { bytes + offset, colorModeLength };
|
||||
offset += colorModeLength;
|
||||
|
||||
if ( offset + 4 > length ) return std::unexpected( idhan::ModuleError { "Truncated file" } );
|
||||
const std::uint32_t resourcesLength { readUint32BE( bytes + offset ) };
|
||||
offset += 4 + resourcesLength;
|
||||
|
||||
if ( offset + 4 > length ) return std::unexpected( idhan::ModuleError { "Truncated file" } );
|
||||
const std::uint32_t layerMaskLength { readUint32BE( bytes + offset ) };
|
||||
offset += 4 + layerMaskLength;
|
||||
|
||||
if ( offset + 2 > length ) return std::unexpected( idhan::ModuleError { "Truncated file" } );
|
||||
const std::uint16_t compression { readUint16BE( bytes + offset ) };
|
||||
offset += 2;
|
||||
|
||||
const std::size_t bytesPerSample { static_cast< std::size_t >( header->depth / 8 ) };
|
||||
const std::size_t planeSize { static_cast< std::size_t >( header->width ) * header->height };
|
||||
std::vector< std::uint8_t > planarData;
|
||||
|
||||
switch ( compression )
|
||||
{
|
||||
case 0: // Uncompressed
|
||||
{
|
||||
std::size_t expectedSize { planeSize * header->channels * bytesPerSample };
|
||||
|
||||
if ( offset + expectedSize > length )
|
||||
{
|
||||
return std::unexpected( idhan::ModuleError { "Insufficient image data" } );
|
||||
}
|
||||
|
||||
planarData.assign( bytes + offset, bytes + offset + expectedSize );
|
||||
}
|
||||
break;
|
||||
case 1: // PackBits
|
||||
{
|
||||
planarData = unpackRaster( bytes, offset, length, header->width, header->height, header->channels );
|
||||
if ( planarData.empty() )
|
||||
{
|
||||
return std::unexpected( idhan::ModuleError { "Failed to decompress RLE data" } );
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return std::unexpected( idhan::ModuleError { "Unsupported compression method" } );
|
||||
}
|
||||
|
||||
std::size_t totalPixels { planeSize * header->channels };
|
||||
std::vector< std::uint8_t > planar8bit { convertToTargetDepth( planarData, header->depth, totalPixels ) };
|
||||
if ( planar8bit.empty() )
|
||||
{
|
||||
return std::unexpected( idhan::ModuleError { "Failed to convert bit depth" } );
|
||||
}
|
||||
|
||||
auto interleavedRGB { convertPlanarToInterleavedRGB(
|
||||
std::basic_string_view( planar8bit.data(), planar8bit.size() ),
|
||||
header->colorMode,
|
||||
header->width,
|
||||
header->height,
|
||||
header->channels,
|
||||
colorTable ) };
|
||||
|
||||
if ( !interleavedRGB.has_value() )
|
||||
{
|
||||
return std::unexpected( interleavedRGB.error() );
|
||||
}
|
||||
|
||||
VipsImage* image { vips_image_new_from_memory(
|
||||
interleavedRGB->data(),
|
||||
interleavedRGB->size(),
|
||||
static_cast< int >( header->width ),
|
||||
static_cast< int >( header->height ),
|
||||
3,
|
||||
VIPS_FORMAT_UCHAR ) };
|
||||
|
||||
if ( !image )
|
||||
{
|
||||
return std::unexpected( idhan::ModuleError { "Failed to create image from PSD data" } );
|
||||
}
|
||||
|
||||
const float source_aspect { static_cast< float >( header->width ) / static_cast< float >( header->height ) };
|
||||
const float target_aspect { static_cast< float >( width ) / static_cast< float >( height ) };
|
||||
|
||||
if ( target_aspect > source_aspect )
|
||||
width = static_cast< std::size_t >( static_cast< float >( height ) * source_aspect );
|
||||
else
|
||||
height = static_cast< std::size_t >( static_cast< float >( width ) / source_aspect );
|
||||
|
||||
VipsImage* resized { nullptr };
|
||||
if ( vips_resize(
|
||||
image,
|
||||
&resized,
|
||||
static_cast< double >( width ) / static_cast< double >( vips_image_get_width( image ) ),
|
||||
nullptr ) )
|
||||
{
|
||||
g_object_unref( image );
|
||||
return std::unexpected( idhan::ModuleError { "Failed to resize image" } );
|
||||
}
|
||||
g_object_unref( image );
|
||||
|
||||
void* output_buffer { nullptr };
|
||||
std::size_t output_length { 0 };
|
||||
if ( vips_pngsave_buffer( resized, &output_buffer, &output_length, nullptr ) )
|
||||
{
|
||||
g_object_unref( resized );
|
||||
return std::unexpected( idhan::ModuleError { "Failed to save thumbnail" } );
|
||||
}
|
||||
g_object_unref( resized );
|
||||
|
||||
std::vector< std::byte > output(
|
||||
static_cast< std::byte* >( output_buffer ), static_cast< std::byte* >( output_buffer ) + output_length );
|
||||
g_free( output_buffer );
|
||||
|
||||
ThumbnailInfo info {};
|
||||
info.data = std::move( output );
|
||||
info.width = width;
|
||||
info.height = height;
|
||||
|
||||
return info;
|
||||
}
|
||||
44
IDHANModules/premade/PsdMetadata.hpp
Normal file
44
IDHANModules/premade/PsdMetadata.hpp
Normal file
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// Created by kj16609 on 11/12/25.
|
||||
//
|
||||
#pragma once
|
||||
#include "MetadataModule.hpp"
|
||||
#include "ThumbnailerModule.hpp"
|
||||
|
||||
class PsdMetadata final : public idhan::MetadataModuleI
|
||||
{
|
||||
public:
|
||||
|
||||
PsdMetadata() = default;
|
||||
|
||||
std::vector< std::string_view > handleableMimes() override;
|
||||
|
||||
std::string_view name() override;
|
||||
|
||||
idhan::ModuleVersion version() override;
|
||||
|
||||
std::expected< idhan::MetadataInfo, idhan::ModuleError > parseFile(
|
||||
const void* data,
|
||||
std::size_t length,
|
||||
std::string mime_name ) override;
|
||||
};
|
||||
|
||||
class PsdThumbnailer final : public idhan::ThumbnailerModuleI
|
||||
{
|
||||
public:
|
||||
|
||||
PsdThumbnailer() = default;
|
||||
|
||||
std::vector< std::string_view > handleableMimes() override;
|
||||
|
||||
std::string_view name() override;
|
||||
|
||||
idhan::ModuleVersion version() override;
|
||||
|
||||
std::expected< ThumbnailInfo, idhan::ModuleError > createThumbnail(
|
||||
const void* data,
|
||||
std::size_t length,
|
||||
std::size_t width,
|
||||
std::size_t height,
|
||||
std::string mime_name ) override;
|
||||
};
|
||||
@@ -12,15 +12,20 @@
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include "FFMPEGMetadata.hpp"
|
||||
#include "FFMPEGThumbnailer.hpp"
|
||||
#include "ImageVipsMetadata.hpp"
|
||||
#include "ImageVipsThumbnailer.hpp"
|
||||
#include "PsdMetadata.hpp"
|
||||
|
||||
using namespace idhan;
|
||||
|
||||
std::vector< std::shared_ptr< IDHANModule > > getModules()
|
||||
{
|
||||
std::vector< std::shared_ptr< IDHANModule > > ret {
|
||||
std::make_shared< ImageVipsMetadata >(), std::make_shared< ImageVipsThumbnailer >()
|
||||
std::make_shared< ImageVipsMetadata >(), std::make_shared< ImageVipsThumbnailer >(),
|
||||
std::make_shared< PsdMetadata >(), std::make_shared< PsdThumbnailer >(),
|
||||
std::make_shared< FFMPEGMetadata >(), std::make_shared< FFMPEGThumbnailer >(),
|
||||
};
|
||||
|
||||
return ret;
|
||||
|
||||
87
IDHANModules/premade/ffmpeg.hpp
Normal file
87
IDHANModules/premade/ffmpeg.hpp
Normal file
@@ -0,0 +1,87 @@
|
||||
//
|
||||
// Created by kj16609 on 11/14/25.
|
||||
//
|
||||
#pragma once
|
||||
|
||||
#include <libavutil/error.h>
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
extern "C" {
|
||||
#include <libavformat/avio.h>
|
||||
}
|
||||
|
||||
#include <cstdint>
|
||||
#include <string_view>
|
||||
|
||||
namespace idhan
|
||||
{
|
||||
namespace log = spdlog;
|
||||
}
|
||||
|
||||
struct OpaqueInfo
|
||||
{
|
||||
std::string_view m_data;
|
||||
std::int64_t m_cursor { 0 };
|
||||
};
|
||||
|
||||
inline int readFunction( void* opaque, std::uint8_t* buffer, int buffer_size )
|
||||
{
|
||||
auto& buffer_view { *static_cast< OpaqueInfo* >( opaque ) };
|
||||
|
||||
const bool cursor_oob { buffer_view.m_cursor > buffer_view.m_data.size() };
|
||||
if ( cursor_oob ) return AVERROR_EOF;
|
||||
|
||||
auto* data { buffer_view.m_data.data() };
|
||||
|
||||
data += buffer_view.m_cursor;
|
||||
const std::int64_t size { static_cast< std::int64_t >( buffer_view.m_data.size() ) - buffer_view.m_cursor };
|
||||
const std::int64_t min_size { std::min( size, static_cast< std::int64_t >( buffer_size ) ) };
|
||||
|
||||
if ( min_size == 0 ) return AVERROR_EOF;
|
||||
|
||||
std::memcpy( buffer, data, min_size );
|
||||
|
||||
buffer_view.m_cursor += min_size;
|
||||
|
||||
return static_cast< int >( min_size );
|
||||
}
|
||||
|
||||
inline std::int64_t seekFunction( void* opaque, std::int64_t offset, int whence )
|
||||
{
|
||||
auto& buffer_view { *static_cast< OpaqueInfo* >( opaque ) };
|
||||
|
||||
idhan::log::info( "Asked to seek from whence {} and offset {}", whence, offset );
|
||||
switch ( whence )
|
||||
{
|
||||
case SEEK_SET:
|
||||
idhan::log::info( "Asked to seek to specific offset {}", offset );
|
||||
buffer_view.m_cursor = offset;
|
||||
break;
|
||||
case SEEK_CUR:
|
||||
idhan::log::info( "Asked to seek to an +{} from cursor", offset );
|
||||
buffer_view.m_cursor += offset;
|
||||
break;
|
||||
case SEEK_END:
|
||||
idhan::log::info( "Asked to seek to end" );
|
||||
buffer_view.m_cursor = static_cast< std::int64_t >( buffer_view.m_data.size() ) + offset;
|
||||
break;
|
||||
case AVSEEK_SIZE:
|
||||
idhan::log::info( "Asked to seek size" );
|
||||
return static_cast< std::int64_t >( buffer_view.m_data.size() );
|
||||
default:
|
||||
{
|
||||
idhan::log::info( "Asked to seek to whence that ended in default" );
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
if ( buffer_view.m_cursor < 0 )
|
||||
buffer_view.m_cursor = 0;
|
||||
else if ( buffer_view.m_cursor >= buffer_view.m_data.size() )
|
||||
buffer_view.m_cursor = buffer_view.m_data.size();
|
||||
|
||||
return buffer_view.m_cursor;
|
||||
}
|
||||
|
||||
inline static std::vector< std::string_view >
|
||||
ffmpeg_handleable_mimes { "video/mp4", "video/webm", "video/mpeg", "video/quicktime" };
|
||||
@@ -13,7 +13,7 @@
|
||||
#include <memory>
|
||||
|
||||
#include "ConnectionArguments.hpp"
|
||||
#include "filesystem/ClusterManager.hpp"
|
||||
#include "filesystem/clusters/ClusterManager.hpp"
|
||||
#include "modules/ModuleLoader.hpp"
|
||||
|
||||
namespace idhan
|
||||
|
||||
@@ -33,9 +33,12 @@ class APIMaintenance : public drogon::HttpController< APIMaintenance >
|
||||
drogon::Task< drogon::HttpResponsePtr > integrityCheck( drogon::HttpRequestPtr request );
|
||||
|
||||
drogon::Task< drogon::HttpResponsePtr > parseMime( drogon::HttpRequestPtr request );
|
||||
drogon::Task< drogon::HttpResponsePtr > createThumbnail( drogon::HttpRequestPtr request );
|
||||
drogon::Task< drogon::HttpResponsePtr > reloadMime( drogon::HttpRequestPtr request );
|
||||
drogon::Task< drogon::HttpResponsePtr > listParsers( drogon::HttpRequestPtr request );
|
||||
|
||||
drogon::Task< drogon::HttpResponsePtr > purgeThumbnails( drogon::HttpRequestPtr request );
|
||||
|
||||
public:
|
||||
|
||||
METHOD_LIST_BEGIN
|
||||
@@ -44,12 +47,15 @@ class APIMaintenance : public drogon::HttpController< APIMaintenance >
|
||||
// ADD_METHOD_TO( IDHANMaintenanceAPI::postgresqlStorage, "/db/stats/chart" );
|
||||
ADD_METHOD_TO( APIMaintenance::postgresqlStorageSunData, "/db/stats/sunburst" );
|
||||
|
||||
ADD_METHOD_TO( APIMaintenance::parseMime, "/mime/parse_file" );
|
||||
ADD_METHOD_TO( APIMaintenance::parseMime, "/mime/parse" );
|
||||
ADD_METHOD_TO( APIMaintenance::createThumbnail, "/mime/generate_thumbnail" );
|
||||
ADD_METHOD_TO( APIMaintenance::reloadMime, "/mime/reload" );
|
||||
ADD_METHOD_TO( APIMaintenance::listParsers, "/mime/parsers" );
|
||||
|
||||
ADD_METHOD_TO( APIMaintenance::integrityCheck, "/integrity" );
|
||||
|
||||
ADD_METHOD_TO( APIMaintenance::purgeThumbnails, "/purge/thumbnails" );
|
||||
|
||||
// ADD_METHOD_TO( APIMaintenance::test, "/test" );
|
||||
|
||||
METHOD_LIST_END
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
#include <drogon/HttpController.h>
|
||||
#include <drogon/utils/coroutine.h>
|
||||
|
||||
#include "threading/ExpectedTask.hpp"
|
||||
#include "IDHANTypes.hpp"
|
||||
#include "db/dbTypes.hpp"
|
||||
#include "helpers/ExpectedTask.hpp"
|
||||
|
||||
namespace idhan::api
|
||||
{
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
#include <fstream>
|
||||
|
||||
#include "../../filesystem/clusters/ClusterManager.hpp"
|
||||
#include "api/ClusterAPI.hpp"
|
||||
#include "api/helpers/createBadRequest.hpp"
|
||||
#include "filesystem/ClusterManager.hpp"
|
||||
#include "logging/log.hpp"
|
||||
|
||||
namespace idhan::api
|
||||
|
||||
@@ -2,21 +2,22 @@
|
||||
// Created by kj16609 on 3/20/25.
|
||||
//
|
||||
|
||||
#include "../../filesystem/io/IOUring.hpp"
|
||||
#include "MetadataModule.hpp"
|
||||
#include "api/ClusterAPI.hpp"
|
||||
#include "api/helpers/ExpectedTask.hpp"
|
||||
#include "api/helpers/createBadRequest.hpp"
|
||||
#include "api/helpers/records.hpp"
|
||||
#include "api/helpers/helpers.hpp"
|
||||
#include "crypto/SHA256.hpp"
|
||||
#include "fgl/size.hpp"
|
||||
#include "filesystem/IOUring.hpp"
|
||||
#include "filesystem/utility.hpp"
|
||||
#include "filesystem/filesystem.hpp"
|
||||
#include "fixme.hpp"
|
||||
#include "hyapi/helpers.hpp"
|
||||
#include "logging/log.hpp"
|
||||
#include "metadata/parseMetadata.hpp"
|
||||
#include "metadata/metadata.hpp"
|
||||
#include "mime/FileInfo.hpp"
|
||||
#include "mime/MimeDatabase.hpp"
|
||||
#include "records/records.hpp"
|
||||
#include "threading/ExpectedTask.hpp"
|
||||
|
||||
namespace idhan::api
|
||||
{
|
||||
@@ -24,7 +25,7 @@ ExpectedTask< RecordID > adoptOrphan( FileIOUring io_uring, DbClientPtr db )
|
||||
{
|
||||
const auto data { co_await io_uring.readAll() };
|
||||
const auto sha256 { SHA256::hash( data.data(), data.size() ) };
|
||||
const auto record_result { co_await helpers::createRecord( sha256, db ) };
|
||||
const auto record_result { co_await idhan::helpers::createRecord( sha256, db ) };
|
||||
|
||||
co_return record_result;
|
||||
}
|
||||
@@ -33,9 +34,9 @@ struct ScanParams
|
||||
{
|
||||
bool read_only:1 { true };
|
||||
bool recompute_hash:1 { false };
|
||||
bool scan_mime:1 { false };
|
||||
bool scan_mime:1 { true };
|
||||
bool rescan_mime:1 { false };
|
||||
bool scan_metadata:1 { false };
|
||||
bool scan_metadata:1 { true };
|
||||
bool rescan_metadata:1 { false };
|
||||
bool stop_on_fail:1 { false };
|
||||
bool adopt_orphans:1 { false };
|
||||
@@ -68,10 +69,10 @@ static ScanParams extractScanParams( const drogon::HttpRequestPtr& request )
|
||||
p.adopt_orphans = request->getOptionalParameter< bool >( "adopt_orphans" ).value_or( false );
|
||||
p.remove_missing_files = request->getOptionalParameter< bool >( "remove_missing_files" ).value_or( false );
|
||||
|
||||
p.scan_metadata = p.scan_metadata || p.adopt_orphans; // orphans will need to be scanned for metadata
|
||||
p.scan_mime = p.scan_mime || p.scan_metadata; // mime is needed for metadata
|
||||
p.recompute_hash = p.recompute_hash || p.adopt_orphans;
|
||||
p.recompute_hash = p.recompute_hash || p.read_only;
|
||||
p.scan_metadata |= p.adopt_orphans; // orphans will need to be scanned for metadata
|
||||
p.scan_mime |= p.scan_metadata; // mime is needed for metadata
|
||||
p.recompute_hash |= p.adopt_orphans;
|
||||
p.recompute_hash |= p.read_only;
|
||||
// if read only then we need to recompute the hash because the file path can't be trusted anymore
|
||||
|
||||
return p;
|
||||
@@ -84,22 +85,24 @@ class ScanContext
|
||||
|
||||
ScanParams m_params {};
|
||||
std::string m_mime_name {};
|
||||
SHA256 m_sha256 {};
|
||||
|
||||
static constexpr auto INVALID_RECORD { std::numeric_limits< RecordID >::max() };
|
||||
RecordID m_record_id { INVALID_RECORD };
|
||||
|
||||
ClusterID m_cluster_id;
|
||||
std::filesystem::path m_cluster_path;
|
||||
|
||||
ExpectedTask< SHA256 > checkSHA256( FileIOUring uring, std::filesystem::path bad_dir );
|
||||
ExpectedTask< SHA256 > checkSHA256( std::filesystem::path bad_dir );
|
||||
|
||||
ExpectedTask< RecordID > checkRecord( SHA256 sha256, DbClientPtr db );
|
||||
ExpectedTask< RecordID > checkRecord( DbClientPtr db );
|
||||
|
||||
ExpectedTask< void > cleanupDoubleClusters( ClusterID found_cluster_id, DbClientPtr db );
|
||||
drogon::Task<> updateFileModifiedTime( drogon::orm::DbClientPtr db );
|
||||
ExpectedTask< void > insertFileInfo( drogon::orm::DbClientPtr db );
|
||||
|
||||
ExpectedTask< void > checkCluster( DbClientPtr db );
|
||||
ExpectedTask< bool > hasMime( DbClientPtr db );
|
||||
drogon::Task< bool > hasMime( DbClientPtr db );
|
||||
|
||||
ExpectedTask<> scanMime( DbClientPtr db );
|
||||
|
||||
@@ -108,11 +111,16 @@ class ScanContext
|
||||
|
||||
public:
|
||||
|
||||
ScanContext( const std::filesystem::path& file_path, const ClusterID cluster_id, const ScanParams params ) :
|
||||
ScanContext(
|
||||
const std::filesystem::path& file_path,
|
||||
const ClusterID cluster_id,
|
||||
const std::filesystem::path& cluster_path,
|
||||
const ScanParams params ) :
|
||||
m_path( file_path ),
|
||||
m_size( std::filesystem::file_size( file_path ) ),
|
||||
m_params( params ),
|
||||
m_cluster_id( cluster_id )
|
||||
m_cluster_id( cluster_id ),
|
||||
m_cluster_path( cluster_path )
|
||||
{}
|
||||
|
||||
ExpectedTask<> scan( std::filesystem::path bad_dir, DbClientPtr db );
|
||||
@@ -129,59 +137,56 @@ drogon::Task< drogon::HttpResponsePtr > ClusterAPI::scan( drogon::HttpRequestPtr
|
||||
|
||||
scan_params.read_only = scan_params.force_readonly || result[ 0 ][ "read_only" ].as< bool >();
|
||||
|
||||
const std::filesystem::path folder_path { result[ 0 ][ "folder_path" ].as< std::string >() };
|
||||
const std::filesystem::path cluster_path { result[ 0 ][ "folder_path" ].as< std::string >() };
|
||||
|
||||
const auto bad_dir { folder_path / "bad" };
|
||||
const auto bad_dir { cluster_path / "bad" };
|
||||
|
||||
std::vector< drogon::Task< std::expected< void, drogon::HttpResponsePtr > > > scan_tasks {};
|
||||
|
||||
std::filesystem::path last_scanned { "" };
|
||||
|
||||
auto dir_itterator { std::filesystem::recursive_directory_iterator( folder_path ) };
|
||||
const auto end { std::filesystem::recursive_directory_iterator() };
|
||||
|
||||
std::vector< ExpectedTask<> > awaiters {};
|
||||
|
||||
while ( dir_itterator != end )
|
||||
for ( const auto& folder : std::filesystem::directory_iterator( cluster_path ) )
|
||||
{
|
||||
const auto entry { *dir_itterator };
|
||||
if ( !folder.is_directory() ) continue;
|
||||
|
||||
const auto& file_path { entry.path() };
|
||||
if ( folder.path() == bad_dir ) continue;
|
||||
|
||||
if ( file_path == bad_dir )
|
||||
for ( const auto& file : std::filesystem::directory_iterator( folder ) )
|
||||
{
|
||||
dir_itterator.disable_recursion_pending();
|
||||
const auto entry { file };
|
||||
|
||||
const auto& file_path { entry.path() };
|
||||
|
||||
log::info( "Scanner hitting path: {}", file_path.string() );
|
||||
|
||||
if ( !entry.is_regular_file() )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// ignore thumbnails
|
||||
if ( file_path.extension() == ".thumbnail" )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( file_path.parent_path() != last_scanned )
|
||||
{
|
||||
last_scanned = file_path.parent_path();
|
||||
log::info( "Scanning {}", last_scanned.string() );
|
||||
}
|
||||
|
||||
ScanContext ctx { file_path, cluster_id, cluster_path, scan_params };
|
||||
|
||||
const std::expected< void, drogon::HttpResponsePtr > file_result { co_await ctx.scan( bad_dir, db ) };
|
||||
|
||||
if ( scan_params.stop_on_fail && !file_result )
|
||||
{
|
||||
co_return file_result.error();
|
||||
};
|
||||
}
|
||||
|
||||
if ( !entry.is_regular_file() )
|
||||
{
|
||||
++dir_itterator;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ignore thumbnails
|
||||
if ( file_path.extension() == ".thumbnail" )
|
||||
{
|
||||
++dir_itterator;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( file_path.parent_path() != last_scanned )
|
||||
{
|
||||
last_scanned = file_path.parent_path();
|
||||
log::info( "Scanning {}", last_scanned.string() );
|
||||
}
|
||||
|
||||
ScanContext ctx { file_path, cluster_id, scan_params };
|
||||
|
||||
const std::expected< void, drogon::HttpResponsePtr > file_result { co_await ctx.scan( bad_dir, db ) };
|
||||
|
||||
if ( scan_params.stop_on_fail && !file_result )
|
||||
{
|
||||
co_return file_result.error();
|
||||
};
|
||||
|
||||
++dir_itterator;
|
||||
}
|
||||
|
||||
co_await drogon::when_all( std::move( scan_tasks ) );
|
||||
@@ -196,9 +201,10 @@ drogon::Task< drogon::HttpResponsePtr > ClusterAPI::scan( drogon::HttpRequestPtr
|
||||
* @param bad_dir Directory to put failed files, Such as ones that have the wrong filename
|
||||
* @return
|
||||
*/
|
||||
ExpectedTask< SHA256 > ScanContext::checkSHA256( FileIOUring uring, const std::filesystem::path bad_dir )
|
||||
ExpectedTask< SHA256 > ScanContext::checkSHA256( const std::filesystem::path bad_dir )
|
||||
{
|
||||
const auto file_stem { m_path.stem().string() };
|
||||
FileIOUring uring { m_path };
|
||||
|
||||
auto sha256_e { m_params.trust_filename ? SHA256::fromHex( file_stem ) : co_await SHA256::hashCoro( uring ) };
|
||||
|
||||
@@ -231,6 +237,7 @@ ExpectedTask< SHA256 > ScanContext::checkSHA256( FileIOUring uring, const std::f
|
||||
{
|
||||
if ( !m_params.read_only )
|
||||
{
|
||||
std::filesystem::create_directories( bad_dir );
|
||||
const auto new_path { bad_dir / m_path.filename() };
|
||||
|
||||
// try to fix the mistake
|
||||
@@ -244,6 +251,15 @@ ExpectedTask< SHA256 > ScanContext::checkSHA256( FileIOUring uring, const std::f
|
||||
new_path.string() ) );
|
||||
}
|
||||
}
|
||||
catch ( std::exception& e )
|
||||
{
|
||||
co_return std::unexpected( createInternalError(
|
||||
"When scanning file at {} it was detected that the filename does not match the sha256 "
|
||||
"{}. There was an error that prevented this from being fixed: {}",
|
||||
m_path.string(),
|
||||
sha256_hex,
|
||||
e.what() ) );
|
||||
}
|
||||
catch ( ... )
|
||||
{
|
||||
co_return std::unexpected( createInternalError(
|
||||
@@ -257,21 +273,21 @@ ExpectedTask< SHA256 > ScanContext::checkSHA256( FileIOUring uring, const std::f
|
||||
co_return *sha256_e;
|
||||
}
|
||||
|
||||
ExpectedTask< RecordID > ScanContext::checkRecord( const SHA256 sha256, drogon::orm::DbClientPtr db )
|
||||
ExpectedTask< RecordID > ScanContext::checkRecord( drogon::orm::DbClientPtr db )
|
||||
{
|
||||
const auto search_result {
|
||||
co_await db->execSqlCoro( "SELECT record_id FROM records WHERE sha256 = $1", sha256.toVec() )
|
||||
co_await db->execSqlCoro( "SELECT record_id FROM records WHERE sha256 = $1", m_sha256.toVec() )
|
||||
};
|
||||
|
||||
if ( search_result.empty() && m_params.adopt_orphans )
|
||||
{
|
||||
const auto insert_result {
|
||||
co_await db->execSqlCoro( "INSERT INTO records (sha256) VALUES ($1) RETURNING record_id", sha256.toVec() )
|
||||
co_await db->execSqlCoro( "INSERT INTO records (sha256) VALUES ($1) RETURNING record_id", m_sha256.toVec() )
|
||||
};
|
||||
|
||||
if ( insert_result.empty() )
|
||||
{
|
||||
co_return std::unexpected( createInternalError( "Failed to create a record for hash {}", sha256.hex() ) );
|
||||
co_return std::unexpected( createInternalError( "Failed to create a record for hash {}", m_sha256.hex() ) );
|
||||
}
|
||||
|
||||
co_return insert_result[ 0 ][ 0 ].as< RecordID >();
|
||||
@@ -343,6 +359,7 @@ ExpectedTask<> ScanContext::insertFileInfo( drogon::orm::DbClientPtr db )
|
||||
|
||||
ExpectedTask<> ScanContext::checkCluster( drogon::orm::DbClientPtr db )
|
||||
{
|
||||
log::debug( "Verifying that the record is in the correct cluster" );
|
||||
FGL_ASSERT( m_record_id != INVALID_RECORD, "Invalid record" );
|
||||
const auto file_info {
|
||||
co_await db->execSqlCoro( "SELECT cluster_id, modified_time FROM file_info WHERE record_id = $1", m_record_id )
|
||||
@@ -371,16 +388,43 @@ ExpectedTask<> ScanContext::checkCluster( drogon::orm::DbClientPtr db )
|
||||
return_unexpected_error( result );
|
||||
}
|
||||
|
||||
// now check if the file is in the right path
|
||||
const auto current_parent { m_path.parent_path() };
|
||||
const auto expected_cluster_subfolder { filesystem::getFileFolder( m_sha256 ) };
|
||||
const auto expected_parent_path { m_cluster_path / expected_cluster_subfolder };
|
||||
|
||||
if ( current_parent != expected_parent_path )
|
||||
{
|
||||
log::warn(
|
||||
"Expected file {} to be in path {} but was found in {} instead (Record {})",
|
||||
m_path.filename().string(),
|
||||
expected_parent_path.string(),
|
||||
current_parent.string(),
|
||||
m_record_id );
|
||||
|
||||
if ( !m_params.read_only )
|
||||
{
|
||||
const auto new_path { expected_parent_path / m_path.filename() };
|
||||
log::info( "Moving file {} to {}", new_path.string(), new_path.string() );
|
||||
|
||||
std::filesystem::create_directories( expected_parent_path );
|
||||
|
||||
std::filesystem::rename( m_path, new_path );
|
||||
|
||||
m_path = new_path;
|
||||
}
|
||||
}
|
||||
|
||||
co_return {};
|
||||
}
|
||||
|
||||
ExpectedTask< bool > ScanContext::hasMime( DbClientPtr db )
|
||||
drogon::Task< bool > ScanContext::hasMime( DbClientPtr db )
|
||||
{
|
||||
auto current_mime { co_await db->execSqlCoro(
|
||||
"SELECT mime_id, name FROM file_info JOIN mime USING (mime_id) WHERE record_id = $1 AND mime_id IS NOT NULL",
|
||||
m_record_id ) };
|
||||
|
||||
if ( !current_mime.empty() )
|
||||
if ( !current_mime.empty() && !current_mime[ 0 ][ "mime_id" ].isNull() )
|
||||
{
|
||||
m_mime_name = current_mime[ 0 ][ 1 ].as< std::string >();
|
||||
co_return true;
|
||||
@@ -395,11 +439,13 @@ ExpectedTask<> ScanContext::scanMime( DbClientPtr db )
|
||||
FileIOUring file_io { m_path };
|
||||
|
||||
// skip checking if we have a mime if we are going to rescan it
|
||||
if ( !m_params.rescan_mime && co_await hasMime( db ) )
|
||||
if ( ( !m_params.rescan_mime ) && co_await hasMime( db ) )
|
||||
{
|
||||
log::debug( "Skipping metadata scan because it already had metadata and rescan_mime was set to false" );
|
||||
co_return {};
|
||||
}
|
||||
|
||||
log::debug( "Starting metadata scan for {} (Record {})", m_path.filename().string(), m_record_id );
|
||||
const auto mime_string_e { co_await mime::getMimeDatabase()->scan( file_io ) };
|
||||
|
||||
const auto mtime { filesystem::getLastWriteTime( m_path ) };
|
||||
@@ -411,9 +457,10 @@ ExpectedTask<> ScanContext::scanMime( DbClientPtr db )
|
||||
if ( extension_str.starts_with( "." ) ) extension_str = extension_str.substr( 1 );
|
||||
|
||||
log::warn(
|
||||
"During a cluster scan file {} failed to be detected by any mime parsers; It has been added despite this and has an extension override of \'{}\'",
|
||||
m_path.string(),
|
||||
extension_str );
|
||||
"During a cluster scan file {} failed to be detected by any mime parsers; It has been added despite this and has an extension override of \'{}\' (Record {})",
|
||||
m_path.filename().string(),
|
||||
extension_str,
|
||||
m_record_id );
|
||||
|
||||
co_await db->execSqlCoro(
|
||||
"INSERT INTO file_info (record_id, size, extension, modified_time) VALUES ($1, $2, $3, $4) ON CONFLICT (record_id) DO UPDATE SET extension = $3, mime_id = NULL",
|
||||
@@ -455,8 +502,10 @@ ExpectedTask<> ScanContext::scanMetadata( DbClientPtr db )
|
||||
// No mime was found in the previous step
|
||||
if ( m_mime_name.empty() )
|
||||
{
|
||||
co_return std::unexpected(
|
||||
createInternalError( "Unable to determine metadata parser for {}: No mime found", m_record_id ) );
|
||||
co_return std::unexpected( createInternalError(
|
||||
"Unable to determine metadata parser for {} (Record {}): No mime found",
|
||||
m_path.filename().string(),
|
||||
m_record_id ) );
|
||||
}
|
||||
|
||||
if ( !m_params.rescan_metadata )
|
||||
@@ -474,7 +523,7 @@ ExpectedTask<> ScanContext::scanMetadata( DbClientPtr db )
|
||||
}
|
||||
}
|
||||
|
||||
const std::shared_ptr< MetadataModuleI > metadata_parser { co_await findBestParser( m_mime_name ) };
|
||||
const std::shared_ptr< MetadataModuleI > metadata_parser { co_await metadata::findBestParser( m_mime_name ) };
|
||||
|
||||
// No parser was found
|
||||
if ( !metadata_parser )
|
||||
@@ -493,7 +542,7 @@ ExpectedTask<> ScanContext::scanMetadata( DbClientPtr db )
|
||||
|
||||
if ( metadata_e )
|
||||
{
|
||||
co_await updateRecordMetadata( m_record_id, db, *metadata_e );
|
||||
co_await metadata::updateRecordMetadata( m_record_id, db, *metadata_e );
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -535,10 +584,11 @@ ExpectedTask< void > ScanContext::checkExtension( DbClientPtr db )
|
||||
if ( expected_extension != file_extension )
|
||||
{
|
||||
log::warn(
|
||||
"When scanning record {}. It was detected that the extension did not match it's mime, Expected {} got {}",
|
||||
m_record_id,
|
||||
"When scanning {} it was detected that the extension did not match it's mime, Expected {} got {} (Record {})",
|
||||
m_path.filename().string(),
|
||||
expected_extension,
|
||||
file_extension );
|
||||
file_extension,
|
||||
m_record_id );
|
||||
|
||||
if ( !m_params.read_only && m_params.fix_extensions )
|
||||
{
|
||||
@@ -560,8 +610,6 @@ drogon::Task< std::expected< void, drogon::HttpResponsePtr > > ScanContext::scan
|
||||
const std::filesystem::path bad_dir,
|
||||
drogon::orm::DbClientPtr db )
|
||||
{
|
||||
FileIOUring io_uring { m_path };
|
||||
|
||||
log::debug( "Scanning file: {}", m_path.string() );
|
||||
|
||||
if ( m_size == 0 )
|
||||
@@ -569,10 +617,12 @@ drogon::Task< std::expected< void, drogon::HttpResponsePtr > > ScanContext::scan
|
||||
"When scanning file: {} it was detected that it has a filesize of zero!", m_path.string() ) );
|
||||
|
||||
// check that the sha256 matches the sha256 name of the file
|
||||
const auto sha256_e { co_await checkSHA256( io_uring, bad_dir ) };
|
||||
const auto sha256_e { co_await checkSHA256( bad_dir ) };
|
||||
return_unexpected_error( sha256_e );
|
||||
|
||||
const auto record_e { co_await checkRecord( *sha256_e, db ) };
|
||||
m_sha256 = *sha256_e;
|
||||
|
||||
const auto record_e { co_await checkRecord( db ) };
|
||||
if ( !record_e ) co_return std::unexpected( record_e.error() );
|
||||
return_unexpected_error( record_e );
|
||||
m_record_id = *record_e;
|
||||
@@ -582,24 +632,30 @@ drogon::Task< std::expected< void, drogon::HttpResponsePtr > > ScanContext::scan
|
||||
// check if the record has been identified in a cluster before
|
||||
const auto cluster_e { co_await checkCluster( db ) };
|
||||
|
||||
if ( m_params.scan_mime )
|
||||
bool has_mime_info { co_await hasMime( db ) };
|
||||
|
||||
if ( ( m_params.scan_mime && !has_mime_info ) || m_params.rescan_mime )
|
||||
{
|
||||
log::debug( "Scanning mime for file {}", m_path.string() );
|
||||
const auto mime_e { co_await scanMime( db ) };
|
||||
if ( !mime_e )
|
||||
{
|
||||
const auto msg( hyapi::helpers::extractHttpResponseErrorMessage( mime_e.error() ) );
|
||||
log::warn( "Failed to process mime for record {} at path {}: {}", m_record_id, m_path.string(), msg );
|
||||
log::warn( "Failed to process mime for {} (Record {}): {}", m_path.filename().string(), m_record_id, msg );
|
||||
co_return std::unexpected( createInternalError(
|
||||
"Failed to process mime for record {} at path {}: {}", m_record_id, m_path.string(), msg ) );
|
||||
"Failed to process mime for {} (Record {}): {}", m_path.filename().string(), m_record_id, msg ) );
|
||||
}
|
||||
has_mime_info = co_await hasMime( db );
|
||||
}
|
||||
|
||||
// extension check
|
||||
const auto extenion_result { co_await checkExtension( db ) };
|
||||
return_unexpected_error( extenion_result );
|
||||
if ( has_mime_info )
|
||||
{
|
||||
// extension check
|
||||
const auto extenion_result { co_await checkExtension( db ) };
|
||||
return_unexpected_error( extenion_result );
|
||||
}
|
||||
|
||||
if ( m_params.scan_metadata )
|
||||
if ( ( m_params.scan_metadata || m_params.rescan_metadata ) && has_mime_info )
|
||||
{
|
||||
log::debug( "Scanning metadata for file {}", m_path.string() );
|
||||
const auto metadata_e { co_await scanMetadata( db ) };
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
|
||||
#include <regex>
|
||||
|
||||
#include "ServerContext.hpp"
|
||||
#include "api/RecordAPI.hpp"
|
||||
#include "api/helpers/createBadRequest.hpp"
|
||||
#include "api/helpers/helpers.hpp"
|
||||
#include "crypto/SHA256.hpp"
|
||||
#include "filesystem/utility.hpp"
|
||||
#include "filesystem/filesystem.hpp"
|
||||
#include "logging/log.hpp"
|
||||
|
||||
namespace idhan::api
|
||||
@@ -17,15 +18,20 @@ namespace idhan::api
|
||||
drogon::Task< drogon::HttpResponsePtr > RecordAPI::fetchFile( drogon::HttpRequestPtr request, RecordID record_id )
|
||||
{
|
||||
const auto db { drogon::app().getFastDbClient() };
|
||||
const auto path_e { co_await filesystem::getFilepath( record_id, db ) };
|
||||
const auto path_e { co_await filesystem::getRecordPath( record_id, db ) };
|
||||
if ( !path_e ) co_return path_e.error();
|
||||
|
||||
if ( !std::filesystem::exists( *path_e ) )
|
||||
{
|
||||
log::warn( "Expected file at location {} for record {} but no file was found", path_e->string(), record_id );
|
||||
co_return createInternalError( "File was expected but not found. Possible data loss" );
|
||||
co_return createInternalError(
|
||||
"File not found at expected location. Record ID: {}, Path: {}. This may indicate data corruption or file system issues.",
|
||||
record_id,
|
||||
path_e->string() );
|
||||
}
|
||||
|
||||
const std::size_t file_size { std::filesystem::file_size( *path_e ) };
|
||||
|
||||
// Check if this is a head request
|
||||
if ( request->isHead() )
|
||||
{
|
||||
@@ -33,15 +39,31 @@ drogon::Task< drogon::HttpResponsePtr > RecordAPI::fetchFile( drogon::HttpReques
|
||||
|
||||
// add to response header that we support partial requests
|
||||
response->addHeader( "Accept-Ranges", "bytes" );
|
||||
response->addHeader( "Content-Length", std::to_string( std::filesystem::file_size( *path_e ) ) );
|
||||
|
||||
response->addHeader( "Content-Length", std::to_string( file_size ) );
|
||||
|
||||
const auto mime_info {
|
||||
co_await db->execSqlCoro( "SELECT mime.name as mime_name FROM file_info JOIN mime USING (mime_id)" )
|
||||
};
|
||||
|
||||
if ( mime_info.empty() )
|
||||
{
|
||||
response->setContentTypeString( "application/octet-stream" );
|
||||
// response->addHeader( "Content-Type", "application/octet-stream" );
|
||||
}
|
||||
else
|
||||
{
|
||||
response->setContentTypeString( mime_info[ 0 ][ "mime_name" ].as< std::string >() );
|
||||
// response->addHeader( "Content-Type", mime_info[ 0 ][ "mime_name" ].as< std::string >() );
|
||||
}
|
||||
|
||||
response->setPassThrough( true );
|
||||
|
||||
co_return response;
|
||||
}
|
||||
|
||||
// Get the header for ranges if supplied
|
||||
|
||||
const std::size_t file_size { std::filesystem::file_size( *path_e ) };
|
||||
|
||||
// Get the header for ranges if supplied
|
||||
const auto& range_header { request->getHeader( "Range" ) };
|
||||
std::size_t begin { 0 };
|
||||
@@ -49,12 +71,16 @@ drogon::Task< drogon::HttpResponsePtr > RecordAPI::fetchFile( drogon::HttpReques
|
||||
|
||||
// This is stupid but apparently valid
|
||||
constexpr auto full_range { "bytes=0-" };
|
||||
if ( !range_header.empty() && range_header != full_range )
|
||||
|
||||
const bool has_range_header { !range_header.empty() };
|
||||
const bool is_full_range { has_range_header && ( range_header == full_range ) };
|
||||
if ( !is_full_range && has_range_header )
|
||||
{
|
||||
static const std::regex range_pattern { R"(bytes=(\d*)-(\d*)?)" };
|
||||
constexpr auto regex_pattern { R"(bytes=(\d*)-(\d*)?)" };
|
||||
static const std::regex regex { regex_pattern };
|
||||
std::smatch range_match {};
|
||||
|
||||
if ( std::regex_match( range_header, range_match, range_pattern ) )
|
||||
if ( std::regex_match( range_header, range_match, regex ) )
|
||||
{
|
||||
if ( range_match.size() != 3 )
|
||||
{
|
||||
@@ -65,14 +91,20 @@ drogon::Task< drogon::HttpResponsePtr > RecordAPI::fetchFile( drogon::HttpReques
|
||||
try
|
||||
{
|
||||
if ( range_match[ 1 ].matched )
|
||||
{
|
||||
log::debug( "Regex range header match 1: {}", range_match[ 1 ].str() );
|
||||
begin = static_cast< std::size_t >( std::stoull( range_match[ 1 ].str() ) );
|
||||
}
|
||||
if ( range_match[ 2 ].matched )
|
||||
{
|
||||
log::debug( "Regex range header match 2: {}", range_match[ 2 ].str() );
|
||||
end = static_cast< std::size_t >( std::stoull( range_match[ 2 ].str() ) );
|
||||
}
|
||||
}
|
||||
catch ( std::exception& e )
|
||||
{
|
||||
log::error( "Error with range header: {}", e.what() );
|
||||
co_return createBadRequest( "Invalid Range Header" );
|
||||
log::error( "Error with range header: {}, Header was {}", e.what(), range_header );
|
||||
co_return createBadRequest( "Error with range header: {}, Header was {}", e.what(), range_header );
|
||||
}
|
||||
|
||||
// Ensure the range is valid
|
||||
@@ -80,14 +112,15 @@ drogon::Task< drogon::HttpResponsePtr > RecordAPI::fetchFile( drogon::HttpReques
|
||||
}
|
||||
else
|
||||
{
|
||||
co_return createBadRequest( "Invalid Range Header Format" );
|
||||
co_return createBadRequest( "Invalid Range Header Format Regex failed: {}", regex_pattern );
|
||||
}
|
||||
}
|
||||
|
||||
if ( request->getOptionalParameter< bool >( "download" ).value_or( false ) )
|
||||
{
|
||||
// send the file as a download instead of letting the browser try to display it
|
||||
co_return drogon::HttpResponse::newFileResponse( path_e->string(), path_e->filename().string() );
|
||||
const auto response { drogon::HttpResponse::newFileResponse( path_e->string(), path_e->filename().string() ) };
|
||||
co_return response;
|
||||
}
|
||||
|
||||
auto response { drogon::HttpResponse::newFileResponse( path_e->string(), begin, end - begin ) };
|
||||
|
||||
@@ -6,75 +6,12 @@
|
||||
#include "api/RecordAPI.hpp"
|
||||
#include "api/helpers/createBadRequest.hpp"
|
||||
#include "crypto/SHA256.hpp"
|
||||
#include "metadata/parseMetadata.hpp"
|
||||
#include "fgl/defines.hpp"
|
||||
#include "metadata/metadata.hpp"
|
||||
|
||||
namespace idhan::api
|
||||
{
|
||||
|
||||
drogon::Task< std::expected< void, drogon::HttpResponsePtr > > addImageInfo(
|
||||
Json::Value& root,
|
||||
const RecordID record_id,
|
||||
DbClientPtr db )
|
||||
{
|
||||
const auto metadata { co_await db->execSqlCoro( "SELECT * FROM image_metadata WHERE record_id = $1", record_id ) };
|
||||
|
||||
if ( metadata.empty() )
|
||||
co_return std::unexpected( createInternalError( "Could not find image metadata for record {}", record_id ) );
|
||||
|
||||
root[ "width" ] = metadata[ 0 ][ "width" ].as< std::uint32_t >();
|
||||
root[ "height" ] = metadata[ 0 ][ "height" ].as< std::uint32_t >();
|
||||
root[ "channels" ] = metadata[ 0 ][ "channels" ].as< std::uint32_t >();
|
||||
|
||||
co_return std::expected< void, drogon::HttpResponsePtr >();
|
||||
}
|
||||
|
||||
drogon::Task< std::expected< void, drogon::HttpResponsePtr > > addFileSpecificInfo(
|
||||
Json::Value& root,
|
||||
const RecordID record_id,
|
||||
DbClientPtr db )
|
||||
{
|
||||
auto simple_mime_result {
|
||||
co_await db->execSqlCoro( "SELECT simple_mime_type FROM metadata WHERE record_id = $1", record_id )
|
||||
};
|
||||
|
||||
if ( simple_mime_result.empty() ) // Could not find any mime info for this record, Try parsing for it.
|
||||
{
|
||||
const auto parsed_metadata { co_await tryParseRecordMetadata( record_id, db ) };
|
||||
|
||||
if ( !parsed_metadata ) co_return std::unexpected( parsed_metadata.error() );
|
||||
|
||||
simple_mime_result =
|
||||
co_await db->execSqlCoro( "SELECT simple_mime_type FROM metadata WHERE record_id = $1", record_id );
|
||||
}
|
||||
|
||||
if ( simple_mime_result.empty() )
|
||||
co_return std::unexpected( createInternalError( "Failed to get simple mime type for record {}", record_id ) );
|
||||
|
||||
const SimpleMimeType simple_mime_type { simple_mime_result[ 0 ][ "simple_mime_type" ].as< std::uint16_t >() };
|
||||
|
||||
switch ( simple_mime_type )
|
||||
{
|
||||
case SimpleMimeType::IMAGE:
|
||||
{
|
||||
const auto result { co_await addImageInfo( root, record_id, db ) };
|
||||
if ( !result ) co_return std::unexpected( result.error() );
|
||||
break;
|
||||
}
|
||||
case SimpleMimeType::VIDEO:
|
||||
break;
|
||||
case SimpleMimeType::ANIMATION:
|
||||
break;
|
||||
case SimpleMimeType::AUDIO:
|
||||
break;
|
||||
case SimpleMimeType::NONE:
|
||||
[[fallthrough]];
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
co_return std::expected< void, drogon::HttpResponsePtr >();
|
||||
}
|
||||
|
||||
drogon::Task< drogon::HttpResponsePtr > RecordAPI::fetchInfo(
|
||||
[[maybe_unused]] drogon::HttpRequestPtr request,
|
||||
RecordID record_id )
|
||||
@@ -92,7 +29,7 @@ drogon::Task< drogon::HttpResponsePtr > RecordAPI::fetchInfo(
|
||||
|
||||
const auto file_info { co_await db->execSqlCoro( "SELECT * FROM file_info WHERE record_id = $1", record_id ) };
|
||||
|
||||
if ( !file_info.empty() )
|
||||
if ( !file_info.empty() && !file_info[ 0 ][ "mime_id" ].isNull() )
|
||||
{
|
||||
root[ "size" ] = file_info[ 0 ][ "size" ].as< std::size_t >();
|
||||
|
||||
@@ -102,7 +39,7 @@ drogon::Task< drogon::HttpResponsePtr > RecordAPI::fetchInfo(
|
||||
root[ "mime" ] = mime_info[ 0 ][ "name" ].as< std::string >();
|
||||
root[ "extension" ] = mime_info[ 0 ][ "best_extension" ].as< std::string >();
|
||||
|
||||
co_await addFileSpecificInfo( root, record_id, db );
|
||||
co_await metadata::addFileSpecificInfo( root, record_id, db );
|
||||
}
|
||||
|
||||
co_return drogon::HttpResponse::newHttpJsonResponse( root );
|
||||
@@ -111,7 +48,7 @@ drogon::Task< drogon::HttpResponsePtr > RecordAPI::fetchInfo(
|
||||
drogon::Task< drogon::HttpResponsePtr > RecordAPI::parseFile( drogon::HttpRequestPtr request, RecordID record_id )
|
||||
{
|
||||
const auto db { drogon::app().getDbClient() };
|
||||
const auto parse_result { co_await tryParseRecordMetadata( record_id, db ) };
|
||||
const auto parse_result { co_await metadata::tryParseRecordMetadata( record_id, db ) };
|
||||
if ( !parse_result ) co_return parse_result.error();
|
||||
|
||||
co_return co_await fetchInfo( request, record_id );
|
||||
|
||||
@@ -4,18 +4,19 @@
|
||||
|
||||
#include <fstream>
|
||||
|
||||
#include "../../filesystem/io/IOUring.hpp"
|
||||
#include "api/RecordAPI.hpp"
|
||||
#include "api/helpers/createBadRequest.hpp"
|
||||
#include "api/helpers/helpers.hpp"
|
||||
#include "crypto/SHA256.hpp"
|
||||
#include "drogon/HttpAppFramework.h"
|
||||
#include "drogon/utils/coroutine.h"
|
||||
#include "filesystem/IOUring.hpp"
|
||||
#include "logging/ScopedTimer.hpp"
|
||||
#include "modules/ModuleLoader.hpp"
|
||||
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wsuggest-override"
|
||||
#include "filesystem/filesystem.hpp"
|
||||
#include "paths.hpp"
|
||||
#include "trantor/utils/ConcurrentTaskQueue.h"
|
||||
#pragma GCC diagnostic pop
|
||||
@@ -70,37 +71,30 @@ drogon::Task< drogon::HttpResponsePtr > RecordAPI::fetchThumbnail( drogon::HttpR
|
||||
// We must generate the thumbnail
|
||||
auto thumbnailers { modules::ModuleLoader::instance().getThumbnailerFor( mime_name ) };
|
||||
|
||||
if ( thumbnailers.size() == 0 )
|
||||
if ( thumbnailers.empty() )
|
||||
{
|
||||
co_return createBadRequest( "No thumbnailer for mime type {} provided by modules", mime_name );
|
||||
}
|
||||
|
||||
auto& thumbnailer { thumbnailers[ 0 ] };
|
||||
|
||||
const auto record_path { co_await helpers::getRecordPath( record_id, db ) };
|
||||
|
||||
if ( !record_path ) co_return record_path.error();
|
||||
|
||||
// FileMappedData data { record_path.value() };
|
||||
FileIOUring io_uring { record_path.value() };
|
||||
auto io_uring_e { co_await filesystem::getIOForRecord( record_id, db ) };
|
||||
if ( !io_uring_e ) co_return io_uring_e.error();
|
||||
auto& io_uring { io_uring_e.value() };
|
||||
|
||||
//TODO: Allow requesting a specific thumbnail size
|
||||
std::size_t height { 256 };
|
||||
std::size_t width { 256 };
|
||||
|
||||
std::vector< std::byte > data { co_await io_uring.readAll() };
|
||||
const auto& [ data, data_size ] = io_uring.mmapReadOnly();
|
||||
|
||||
const auto thumbnail_info {
|
||||
thumbnailer->createThumbnail( data.data(), data.size(), width, height, mime_name )
|
||||
};
|
||||
const auto thumbnail_info { thumbnailer->createThumbnail( data, data_size, width, height, mime_name ) };
|
||||
|
||||
if ( !thumbnail_info ) co_return createInternalError( "Thumbnailer had an error: {}", thumbnail_info.error() );
|
||||
|
||||
std::filesystem::create_directories( thumbnail_location_e.value().parent_path() );
|
||||
|
||||
// const auto& thumbnail_data = thumbnail_info.value().data;
|
||||
auto thumbnail_data { std::make_shared< std::vector< std::byte > >( thumbnail_info.value().data ) };
|
||||
|
||||
const auto& thumbnail_location { thumbnail_location_e.value() };
|
||||
|
||||
std::filesystem::create_directories( thumbnail_location.parent_path() );
|
||||
@@ -108,11 +102,12 @@ drogon::Task< drogon::HttpResponsePtr > RecordAPI::fetchThumbnail( drogon::HttpR
|
||||
|
||||
log::debug( "Writing thumbnail to {}", thumbnail_location.string() );
|
||||
|
||||
co_await io_uring_write.write( *thumbnail_data );
|
||||
co_await io_uring_write.write( thumbnail_info->data );
|
||||
}
|
||||
|
||||
auto response { drogon::HttpResponse::newFileResponse(
|
||||
thumbnail_location_e.value(), thumbnail_location_e.value().filename(), drogon::ContentType::CT_IMAGE_PNG ) };
|
||||
auto response {
|
||||
drogon::HttpResponse::newFileResponse( thumbnail_location_e.value(), "", drogon::ContentType::CT_IMAGE_PNG )
|
||||
};
|
||||
|
||||
const auto duration { std::chrono::hours( 1 ) };
|
||||
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
// Created by kj16609 on 11/15/24.
|
||||
//
|
||||
|
||||
#include "../../filesystem/clusters/ClusterManager.hpp"
|
||||
#include "../../records/records.hpp"
|
||||
#include "api/ImportAPI.hpp"
|
||||
#include "api/helpers/createBadRequest.hpp"
|
||||
#include "api/helpers/records.hpp"
|
||||
#include "codes/ImportCodes.hpp"
|
||||
#include "crypto/SHA256.hpp"
|
||||
#include "db/drogonArrayBind.hpp"
|
||||
#include "filesystem/ClusterManager.hpp"
|
||||
#include "logging/log.hpp"
|
||||
#include "metadata/parseMetadata.hpp"
|
||||
#include "metadata/metadata.hpp"
|
||||
#include "mime/MimeDatabase.hpp"
|
||||
|
||||
namespace idhan::api
|
||||
@@ -160,7 +160,7 @@ drogon::Task< drogon::HttpResponsePtr > ImportAPI::importFile( const drogon::Htt
|
||||
|
||||
const auto response { drogon::HttpResponse::newHttpJsonResponse( root ) };
|
||||
|
||||
co_await tryParseRecordMetadata( record_id, db );
|
||||
co_await metadata::tryParseRecordMetadata( record_id, db );
|
||||
|
||||
co_return response;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
#include "checkContentType.hpp"
|
||||
|
||||
namespace idhan
|
||||
namespace idhan::api::helpers
|
||||
{
|
||||
//! Responds with that the content type is unsupported or unknown
|
||||
void checkContentType(
|
||||
@@ -23,4 +23,4 @@ void checkContentType(
|
||||
|
||||
callback( drogon::HttpResponse::newHttpJsonResponse( json ) );
|
||||
}
|
||||
} // namespace idhan
|
||||
} // namespace idhan::api::helpers
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
#include "ResponseCallback.hpp"
|
||||
|
||||
namespace idhan
|
||||
namespace idhan::api::helpers
|
||||
{
|
||||
//! Responds with that the content type is unsupported or unknown
|
||||
void checkContentType(
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
namespace idhan::api::helpers
|
||||
{
|
||||
|
||||
std::expected< TagDomainID, drogon::HttpResponsePtr > getTagDomainID( drogon::HttpRequestPtr request )
|
||||
std::expected< TagDomainID, drogon::HttpResponsePtr > getTagDomainIDParameter( const drogon::HttpRequestPtr& request )
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
@@ -3,21 +3,8 @@
|
||||
//
|
||||
#pragma once
|
||||
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Weffc++"
|
||||
#pragma GCC diagnostic ignored "-Wredundant-tags"
|
||||
#pragma GCC diagnostic ignored "-Wcast-qual"
|
||||
#pragma GCC diagnostic ignored "-Wold-style-cast"
|
||||
#pragma GCC diagnostic ignored "-Wnoexcept"
|
||||
#pragma GCC diagnostic ignored "-Wredundant-decls"
|
||||
#pragma GCC diagnostic ignored "-Wuseless-cast"
|
||||
#pragma GCC diagnostic ignored "-Wnoexcept"
|
||||
#pragma GCC diagnostic ignored "-Wswitch-enum"
|
||||
#pragma GCC diagnostic ignored "-Wshadow"
|
||||
#include <drogon/HttpResponse.h>
|
||||
#include <drogon/orm/DbClient.h>
|
||||
#include <drogon/utils/coroutine.h>
|
||||
#pragma GCC diagnostic pop
|
||||
|
||||
#include <expected>
|
||||
#include <vector>
|
||||
@@ -28,11 +15,7 @@
|
||||
namespace idhan::api::helpers
|
||||
{
|
||||
|
||||
drogon::Task< std::expected< std::filesystem::path, drogon::HttpResponsePtr > > getRecordPath(
|
||||
RecordID record_id,
|
||||
DbClientPtr db );
|
||||
|
||||
std::expected< TagDomainID, drogon::HttpResponsePtr > getTagDomainID( drogon::HttpRequestPtr request );
|
||||
std::expected< TagDomainID, drogon::HttpResponsePtr > getTagDomainIDParameter( const drogon::HttpRequestPtr& request );
|
||||
|
||||
constexpr std::chrono::seconds default_max_age {
|
||||
std::chrono::duration_cast< std::chrono::seconds >( std::chrono::years( 1 ) )
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
#include "IDHANTypes.hpp"
|
||||
#include "api/APIMaintenance.hpp"
|
||||
#include "metadata/parseMetadata.hpp"
|
||||
#include "logging/log.hpp"
|
||||
#include "metadata/metadata.hpp"
|
||||
|
||||
namespace idhan::api
|
||||
{
|
||||
@@ -19,9 +20,11 @@ drogon::Task< drogon::HttpResponsePtr > APIMaintenance::rescanMetadata(
|
||||
{
|
||||
const auto record_id { row[ "record_id" ].as< RecordID >() };
|
||||
|
||||
co_await tryParseRecordMetadata( record_id, db );
|
||||
co_await metadata::tryParseRecordMetadata( record_id, db );
|
||||
}
|
||||
|
||||
log::info( "Finished scanning metadata for records" );
|
||||
|
||||
co_return drogon::HttpResponse::newHttpJsonResponse( Json::Value() );
|
||||
}
|
||||
|
||||
|
||||
71
IDHANServer/src/api/maintenance/generateThumbnail.cpp
Normal file
71
IDHANServer/src/api/maintenance/generateThumbnail.cpp
Normal file
@@ -0,0 +1,71 @@
|
||||
//
|
||||
// Created by kj16609 on 10/21/25.
|
||||
//
|
||||
|
||||
#include "api/APIMaintenance.hpp"
|
||||
#include "api/helpers/createBadRequest.hpp"
|
||||
#include "mime/MimeDatabase.hpp"
|
||||
#include "modules/ModuleLoader.hpp"
|
||||
|
||||
namespace idhan::api
|
||||
{
|
||||
|
||||
drogon::Task< drogon::HttpResponsePtr > APIMaintenance::createThumbnail( drogon::HttpRequestPtr request )
|
||||
{
|
||||
if ( request->contentType() != drogon::CT_APPLICATION_OCTET_STREAM )
|
||||
co_return createBadRequest(
|
||||
"Content type must be octet-stream was {}", static_cast< int >( request->contentType() ) );
|
||||
const auto request_data { request->getBody() };
|
||||
|
||||
if ( request_data.empty() )
|
||||
{
|
||||
Json::Value error;
|
||||
error[ "error" ] = "No data provided in POST request";
|
||||
co_return drogon::HttpResponse::newHttpJsonResponse( error );
|
||||
}
|
||||
|
||||
const auto mime_str { co_await mime::getMimeDatabase()->scan( request_data ) };
|
||||
|
||||
if ( !mime_str )
|
||||
{
|
||||
Json::Value response;
|
||||
response[ "success" ] = false;
|
||||
response[ "error" ] = "Failed to parse mime type";
|
||||
co_return drogon::HttpResponse::newHttpJsonResponse( response );
|
||||
}
|
||||
|
||||
auto thumbnailers { modules::ModuleLoader::instance().getThumbnailerFor( *mime_str ) };
|
||||
|
||||
if ( thumbnailers.empty() )
|
||||
{
|
||||
Json::Value response;
|
||||
response[ "success" ] = false;
|
||||
response[ "error" ] = "Failed to find thumbnailer for mime";
|
||||
co_return drogon::HttpResponse::newHttpJsonResponse( response );
|
||||
}
|
||||
|
||||
//grab first thumbnailer
|
||||
auto thumbnailer { thumbnailers.at( 0 ) };
|
||||
|
||||
const auto thumbnail_data {
|
||||
thumbnailer->createThumbnail( request_data.data(), request_data.size(), 128, 128, *mime_str )
|
||||
};
|
||||
|
||||
if ( !thumbnailer )
|
||||
{
|
||||
Json::Value response;
|
||||
response[ "success" ] = false;
|
||||
response[ "error" ] = "Failed to parse thumbnail type";
|
||||
co_return drogon::HttpResponse::newHttpJsonResponse( response );
|
||||
}
|
||||
|
||||
const auto& thumb_info { *thumbnail_data };
|
||||
|
||||
auto response = drogon::HttpResponse::newHttpResponse();
|
||||
response->setContentTypeCode( drogon::CT_IMAGE_PNG );
|
||||
response->setBody(
|
||||
std::string( reinterpret_cast< const char* >( thumb_info.data.data() ), thumb_info.data.size() ) );
|
||||
co_return response;
|
||||
}
|
||||
|
||||
} // namespace idhan::api
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "api/APIMaintenance.hpp"
|
||||
#include "api/helpers/createBadRequest.hpp"
|
||||
#include "mime/MimeDatabase.hpp"
|
||||
#include "modules/ModuleLoader.hpp"
|
||||
|
||||
namespace idhan::api
|
||||
{
|
||||
@@ -12,7 +13,8 @@ namespace idhan::api
|
||||
drogon::Task< drogon::HttpResponsePtr > APIMaintenance::parseMime( drogon::HttpRequestPtr request )
|
||||
{
|
||||
if ( request->contentType() != drogon::CT_APPLICATION_OCTET_STREAM )
|
||||
co_return createBadRequest( "Content type must be octet-stream" );
|
||||
co_return createBadRequest(
|
||||
"Content type must be octet-stream was {}", static_cast< int >( request->contentType() ) );
|
||||
const auto request_data { request->getBody() };
|
||||
|
||||
if ( request_data.empty() )
|
||||
@@ -36,6 +38,20 @@ drogon::Task< drogon::HttpResponsePtr > APIMaintenance::parseMime( drogon::HttpR
|
||||
response[ "success" ] = true;
|
||||
response[ "mime" ] = mime_str.value();
|
||||
|
||||
auto metadata_modules { modules::ModuleLoader::instance().getParserFor( *mime_str ) };
|
||||
|
||||
response[ "metadata_modules" ] = {};
|
||||
|
||||
for ( const auto& metadata_module : metadata_modules )
|
||||
{
|
||||
Json::Value metadata_obj {};
|
||||
metadata_obj[ "name" ] = std::string( metadata_module->name() );
|
||||
|
||||
auto metadata_info { metadata_module->parseFile( request_data.data(), request_data.size(), *mime_str ) };
|
||||
|
||||
response[ "metadata_modules" ].append( std::move( metadata_obj ) );
|
||||
}
|
||||
|
||||
co_return drogon::HttpResponse::newHttpJsonResponse( response );
|
||||
}
|
||||
|
||||
|
||||
102
IDHANServer/src/api/maintenance/purgeThumbnails.cpp
Normal file
102
IDHANServer/src/api/maintenance/purgeThumbnails.cpp
Normal file
@@ -0,0 +1,102 @@
|
||||
//
|
||||
// Created by kj16609 on 11/14/25.
|
||||
//
|
||||
|
||||
#include "../APIMaintenance.hpp"
|
||||
|
||||
#include "../../paths.hpp"
|
||||
#include "../../logging/log.hpp"
|
||||
|
||||
#include <filesystem>
|
||||
|
||||
namespace idhan::api
|
||||
{
|
||||
|
||||
drogon::Task< drogon::HttpResponsePtr > APIMaintenance::purgeThumbnails( drogon::HttpRequestPtr request )
|
||||
{
|
||||
try
|
||||
{
|
||||
const auto thumbnails_path { getThumbnailsPath() };
|
||||
|
||||
if ( !std::filesystem::exists( thumbnails_path ) )
|
||||
{
|
||||
log::warn( "Thumbnails directory does not exist: {}", thumbnails_path.string() );
|
||||
|
||||
Json::Value response;
|
||||
response[ "success" ] = true;
|
||||
response[ "message" ] = "Thumbnails directory does not exist";
|
||||
response[ "deleted_count" ] = 0;
|
||||
|
||||
auto resp { drogon::HttpResponse::newHttpJsonResponse( response ) };
|
||||
resp->setStatusCode( drogon::k200OK );
|
||||
co_return resp;
|
||||
}
|
||||
|
||||
std::size_t deleted_count { 0 };
|
||||
std::size_t failed_count { 0 };
|
||||
|
||||
log::info( "Starting thumbnail purge from: {}", thumbnails_path.string() );
|
||||
|
||||
// Iterate through all files in the thumbnails directory
|
||||
for ( const auto& entry : std::filesystem::recursive_directory_iterator( thumbnails_path ) )
|
||||
{
|
||||
if ( entry.is_regular_file() )
|
||||
{
|
||||
try
|
||||
{
|
||||
std::filesystem::remove( entry.path() );
|
||||
++deleted_count;
|
||||
log::trace( "Deleted thumbnail: {}", entry.path().string() );
|
||||
}
|
||||
catch ( const std::filesystem::filesystem_error& e )
|
||||
{
|
||||
++failed_count;
|
||||
log::error( "Failed to delete thumbnail {}: {}", entry.path().string(), e.what() );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up empty directories
|
||||
try
|
||||
{
|
||||
for ( const auto& entry : std::filesystem::recursive_directory_iterator( thumbnails_path ) )
|
||||
{
|
||||
if ( entry.is_directory() && std::filesystem::is_empty( entry.path() ) )
|
||||
{
|
||||
std::filesystem::remove( entry.path() );
|
||||
log::trace( "Removed empty directory: {}", entry.path().string() );
|
||||
}
|
||||
}
|
||||
}
|
||||
catch ( const std::filesystem::filesystem_error& e )
|
||||
{
|
||||
log::warn( "Error cleaning up empty directories: {}", e.what() );
|
||||
}
|
||||
|
||||
log::info( "Thumbnail purge complete. Deleted: {}, Failed: {}", deleted_count, failed_count );
|
||||
|
||||
Json::Value response;
|
||||
response[ "success" ] = true;
|
||||
response[ "deleted_count" ] = static_cast< Json::UInt64 >( deleted_count );
|
||||
response[ "failed_count" ] = static_cast< Json::UInt64 >( failed_count );
|
||||
response[ "message" ] = "Thumbnails purged successfully";
|
||||
|
||||
auto resp { drogon::HttpResponse::newHttpJsonResponse( response ) };
|
||||
resp->setStatusCode( drogon::k200OK );
|
||||
co_return resp;
|
||||
}
|
||||
catch ( const std::exception& e )
|
||||
{
|
||||
log::error( "Error purging thumbnails: {}", e.what() );
|
||||
|
||||
Json::Value response;
|
||||
response[ "success" ] = false;
|
||||
response[ "error" ] = e.what();
|
||||
|
||||
auto resp { drogon::HttpResponse::newHttpJsonResponse( response ) };
|
||||
resp->setStatusCode( drogon::k500InternalServerError );
|
||||
co_return resp;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace idhan::api
|
||||
@@ -2,10 +2,10 @@
|
||||
// Created by kj16609 on 11/17/24.
|
||||
//
|
||||
|
||||
#include "../../records/records.hpp"
|
||||
#include "IDHANTypes.hpp"
|
||||
#include "api/RecordAPI.hpp"
|
||||
#include "api/helpers/createBadRequest.hpp"
|
||||
#include "api/helpers/records.hpp"
|
||||
#include "crypto/SHA256.hpp"
|
||||
#include "fgl/defines.hpp"
|
||||
#include "logging/ScopedTimer.hpp"
|
||||
|
||||
@@ -243,7 +243,7 @@ drogon::Task< drogon::HttpResponsePtr > RecordAPI::addTags(
|
||||
|
||||
if ( !tag_pair_ids ) co_return tag_pair_ids.error();
|
||||
|
||||
const auto tag_domain_id { helpers::getTagDomainID( request ) };
|
||||
const auto tag_domain_id { helpers::getTagDomainIDParameter( request ) };
|
||||
|
||||
if ( !tag_domain_id ) co_return tag_domain_id.error();
|
||||
|
||||
@@ -271,7 +271,7 @@ drogon::Task< drogon::HttpResponsePtr > RecordAPI::addMultipleTags( drogon::Http
|
||||
if ( !json[ "records" ].isArray() )
|
||||
co_return createBadRequest( "Invalid json: Array of ids called 'records' must be present." );
|
||||
|
||||
const auto tag_domain_id { helpers::getTagDomainID( request ) };
|
||||
const auto tag_domain_id { helpers::getTagDomainIDParameter( request ) };
|
||||
|
||||
if ( !tag_domain_id ) co_return tag_domain_id.error();
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
|
||||
#include <expected>
|
||||
|
||||
#include "../../../urls/urls.hpp"
|
||||
#include "IDHANTypes.hpp"
|
||||
#include "api/RecordAPI.hpp"
|
||||
#include "api/helpers/createBadRequest.hpp"
|
||||
#include "api/helpers/urls.hpp"
|
||||
|
||||
namespace idhan::api
|
||||
{
|
||||
@@ -27,7 +27,11 @@ drogon::Task< drogon::HttpResponsePtr > RecordAPI::addUrls( drogon::HttpRequestP
|
||||
{
|
||||
const auto url_id { co_await helpers::findOrCreateUrl( url.asString(), db ) };
|
||||
|
||||
if ( !url_id ) co_return url_id.error();
|
||||
if ( !url_id )
|
||||
{
|
||||
log::error( "Failed to find or create url: {}", url.asString() );
|
||||
co_return url_id.error();
|
||||
}
|
||||
|
||||
co_await db->execSqlCoro(
|
||||
"INSERT INTO url_mappings (url_id, record_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", *url_id, record_id );
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
// Created by kj16609 on 7/24/25.
|
||||
//
|
||||
|
||||
#include "../../../urls/urls.hpp"
|
||||
#include "IDHANTypes.hpp"
|
||||
#include "api/RecordAPI.hpp"
|
||||
#include "api/helpers/createBadRequest.hpp"
|
||||
#include "api/helpers/urls.hpp"
|
||||
|
||||
namespace idhan::api
|
||||
{
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
#include <expected>
|
||||
|
||||
#include "IDHANTypes.hpp"
|
||||
#include "api/helpers/ExpectedTask.hpp"
|
||||
#include "db/dbTypes.hpp"
|
||||
#include "drogon/orm/DbClient.h"
|
||||
#include "drogon/utils/coroutine.h"
|
||||
#include "threading/ExpectedTask.hpp"
|
||||
|
||||
namespace idhan::helpers
|
||||
{
|
||||
|
||||
@@ -31,7 +31,7 @@ drogon::Task< drogon::HttpResponsePtr > SearchAPI::search( drogon::HttpRequestPt
|
||||
|
||||
SearchBuilder builder {};
|
||||
|
||||
builder.setTags( tag_ids );
|
||||
builder.setPositiveTags( tag_ids );
|
||||
|
||||
const auto result { co_await builder.query( db, tag_domain_ids ) };
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ drogon::Task< drogon::HttpResponsePtr > TagAPI::createTagAliases( drogon::HttpRe
|
||||
|
||||
const auto db { drogon::app().getDbClient() };
|
||||
|
||||
const auto tag_domain_id { helpers::getTagDomainID( request ) };
|
||||
const auto tag_domain_id { helpers::getTagDomainIDParameter( request ) };
|
||||
|
||||
if ( !tag_domain_id ) co_return tag_domain_id.error();
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@ drogon::Task< Json::Value > getSimilarTags(
|
||||
{
|
||||
LOG_DEBUG << "Searching for tag \"{}\"" << search_value;
|
||||
|
||||
const auto wrapped_search_value { '%' + search_value + '%' };
|
||||
const bool is_negative { search_value.starts_with( '-' ) };
|
||||
const std::string real_search_value { is_negative ? search_value.substr( 1 ) : search_value };
|
||||
const auto wrapped_search_value { format_ns::format( "%{}%", real_search_value ) };
|
||||
|
||||
constexpr std::size_t max_limit { 32 };
|
||||
|
||||
@@ -41,7 +43,7 @@ drogon::Task< Json::Value > getSimilarTags(
|
||||
limit $3
|
||||
)",
|
||||
wrapped_search_value,
|
||||
search_value,
|
||||
real_search_value,
|
||||
std::min( limit, max_limit ) ) };
|
||||
|
||||
Json::Value tags { Json::arrayValue };
|
||||
@@ -50,8 +52,10 @@ drogon::Task< Json::Value > getSimilarTags(
|
||||
{
|
||||
Json::Value tag;
|
||||
|
||||
tag[ "value" ] = row[ "tag_text" ].as< std::string >();
|
||||
tag[ "tag_text" ] = row[ "tag_text" ].as< std::string >();
|
||||
const auto tag_text { row[ "tag_text" ].as< std::string >() };
|
||||
|
||||
tag[ "value" ] = tag_text;
|
||||
tag[ "tag_text" ] = tag_text;
|
||||
|
||||
tag[ "similarity" ] = row[ "similarity" ].as< double >();
|
||||
tag[ "tag_id" ] = row[ "tag_id" ].as< TagID >();
|
||||
|
||||
@@ -18,8 +18,8 @@ struct hash< std::pair< std::string, std::string > >
|
||||
};
|
||||
} // namespace std
|
||||
|
||||
#include "threading/ExpectedTask.hpp"
|
||||
#include "api/TagAPI.hpp"
|
||||
#include "api/helpers/ExpectedTask.hpp"
|
||||
#include "api/helpers/createBadRequest.hpp"
|
||||
#include "db/drogonArrayBind.hpp"
|
||||
#include "fgl/defines.hpp"
|
||||
|
||||
@@ -30,7 +30,7 @@ drogon::Task< drogon::HttpResponsePtr > TagAPI::createTagParents( const drogon::
|
||||
|
||||
const auto db { drogon::app().getDbClient() };
|
||||
|
||||
const auto tag_domain_id { helpers::getTagDomainID( request ) };
|
||||
const auto tag_domain_id { helpers::getTagDomainIDParameter( request ) };
|
||||
|
||||
if ( !tag_domain_id ) co_return tag_domain_id.error();
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// Created by kj16609 on 11/19/24.
|
||||
//
|
||||
|
||||
#include "threading/ExpectedTask.hpp"
|
||||
#include "IDHANTypes.hpp"
|
||||
#include "api/helpers/ExpectedTask.hpp"
|
||||
#include "api/helpers/createBadRequest.hpp"
|
||||
#include "crypto/SHA256.hpp"
|
||||
#include "drogon/HttpAppFramework.h"
|
||||
|
||||
@@ -4,68 +4,200 @@
|
||||
|
||||
#include "SearchBuilder.hpp"
|
||||
|
||||
#include <ranges>
|
||||
|
||||
#include "api/helpers/helpers.hpp"
|
||||
#include "db/drogonArrayBind.hpp"
|
||||
#include "drogon/HttpAppFramework.h"
|
||||
#include "fgl/defines.hpp"
|
||||
#include "logging/log.hpp"
|
||||
#include "tags/tags.hpp"
|
||||
|
||||
namespace idhan
|
||||
{
|
||||
|
||||
std::string SearchBuilder::construct(
|
||||
const bool return_ids,
|
||||
const bool return_hashes,
|
||||
[[maybe_unused]] const bool filter_domains )
|
||||
void SearchBuilder::parseRangeSearch( RangeSearchInfo& target, std::string_view tag )
|
||||
{
|
||||
// TODO: Sort tag ids to get the most out of each filter.
|
||||
target.m_active = true;
|
||||
|
||||
std::string query {};
|
||||
query.reserve( 1024 );
|
||||
const bool is_greater_than { tag.contains( ">" ) };
|
||||
const bool is_less_than { tag.contains( "<" ) };
|
||||
const bool is_equal_to { tag.contains( "=" ) };
|
||||
const bool is_not { tag.contains( "!" ) || tag.contains( "≠" ) }; // ew
|
||||
const bool is_approximate { tag.contains( "~" ) };
|
||||
|
||||
if ( m_tags.empty() )
|
||||
SearchOperation op { 0 };
|
||||
if ( is_greater_than ) op |= SearchOperationFlags::GreaterThan;
|
||||
if ( is_less_than ) op |= SearchOperationFlags::LessThan;
|
||||
if ( is_equal_to ) op |= SearchOperationFlags::Equal;
|
||||
if ( is_not ) op |= SearchOperationFlags::Not;
|
||||
if ( is_approximate ) op |= SearchOperationFlags::Approximate;
|
||||
target.operation = op;
|
||||
|
||||
log::debug( "Parsing range for {}", tag );
|
||||
|
||||
// find begining of number
|
||||
const auto number_start { tag.find_first_of( "0123456789" ) };
|
||||
const auto number_end { tag.find_last_of( "0123456789" ) };
|
||||
|
||||
const std::string number_substr { tag.substr( number_start, number_end ) };
|
||||
|
||||
log::debug( "Got number from \'{}\'", number_substr );
|
||||
|
||||
try
|
||||
{
|
||||
return "SELECT record_id FROM file_info WHERE mime_id IS NOT NULL";
|
||||
std::size_t remaining_characters_pos { 0 };
|
||||
target.count = std::stoull( number_substr, &remaining_characters_pos );
|
||||
}
|
||||
catch ( std::exception& e )
|
||||
{
|
||||
throw std::invalid_argument(
|
||||
format_ns::format( "Failed to parse number using stoull: {}: {}", tag, e.what() ) );
|
||||
}
|
||||
}
|
||||
|
||||
std::unordered_map< TagID, std::string > SearchBuilder::createFilters(
|
||||
const std::vector< TagID >& tag_ids,
|
||||
const bool filter_domains )
|
||||
{
|
||||
std::unordered_map< TagID, std::string > filters {};
|
||||
filters.reserve( tag_ids.size() );
|
||||
|
||||
// 0 == filter_id, 1 == tag_id
|
||||
// uses $1 for domains
|
||||
constexpr std::string_view domain_filter_template {
|
||||
"filter_{0} AS ( SELECT DISTINCT record_id FROM active_tag_mappings WHERE tag_id = {1} AND ideal_tag_id IS NULL AND tag_domain_id = ANY($1) UNION DISTINCT SELECT DISTINCT record_id FROM active_tag_mappings WHERE ideal_tag_id = {1} AND tag_domain_id = ANY($1) UNION DISTINCT SELECT DISTINCT record_id FROM active_tag_mappings_parents WHERE tag_id = {1} AND tag_domain_id = ANY($1) )"
|
||||
// "filter_{0} AS (SELECT record_id FROM active_tag_mappings WHERE ideal_tag_id IS NULL AND tag_id = {1} AND tag_domain_id = "
|
||||
// "ANY($1) UNION DISTINCT SELECT record_id FROM active_tag_mappings_parents WHERE tag_id = {1} AND tag_domain_id "
|
||||
// "= ANY($1))"
|
||||
"filter_{0} AS ( SELECT record_id FROM active_tag_mappings WHERE tag_id = {1} AND ideal_tag_id IS NULL AND tag_domain_id = ANY($1) UNION DISTINCT SELECT record_id FROM active_tag_mappings WHERE ideal_tag_id = {1} AND tag_domain_id = ANY($1) UNION DISTINCT SELECT record_id FROM active_tag_mappings_parents WHERE tag_id = {1} AND tag_domain_id = ANY($1) )"
|
||||
};
|
||||
|
||||
// 0 == filter_id, 1 == tag_id
|
||||
// Has no binds
|
||||
constexpr std::string_view domainless_filter_template {
|
||||
"filter_{0} AS ( SELECT DISTINCT record_id FROM active_tag_mappings WHERE tag_id = {1} AND ideal_tag_id IS NULL UNION DISTINCT SELECT DISTINCT record_id FROM active_tag_mappings WHERE ideal_tag_id = {1} UNION DISTINCT SELECT DISTINCT record_id FROM active_tag_mappings_parents WHERE tag_id = {1} )"
|
||||
"filter_{0} AS ( SELECT record_id FROM active_tag_mappings WHERE tag_id = {1} AND ideal_tag_id IS NULL UNION DISTINCT SELECT record_id FROM active_tag_mappings WHERE ideal_tag_id = {1} UNION DISTINCT SELECT record_id FROM active_tag_mappings_parents WHERE tag_id = {1} )"
|
||||
};
|
||||
|
||||
m_bind_domains = filter_domains;
|
||||
|
||||
query += "WITH ";
|
||||
|
||||
std::string final_filter { "final_filter AS (" };
|
||||
|
||||
for ( std::size_t i = 0; i < m_tags.size(); ++i )
|
||||
for ( const auto& tag : tag_ids )
|
||||
{
|
||||
auto& tag { m_tags[ i ] };
|
||||
const auto filled_template { filter_domains ? format_ns::format( domain_filter_template, tag, tag ) :
|
||||
format_ns::format( domainless_filter_template, tag, tag ) };
|
||||
|
||||
if ( filter_domains )
|
||||
query += format_ns::format( domain_filter_template, i, tag );
|
||||
else
|
||||
query += format_ns::format( domainless_filter_template, i, tag );
|
||||
final_filter += format_ns::format( "SELECT record_id FROM filter_{}", i );
|
||||
|
||||
if ( i + 1 != m_tags.size() )
|
||||
{
|
||||
query += ",\n";
|
||||
// We have more to process
|
||||
final_filter += " INTERSECT ";
|
||||
}
|
||||
filters.insert_or_assign( tag, filled_template );
|
||||
}
|
||||
|
||||
query += ",\n";
|
||||
query += final_filter;
|
||||
query += ")\n";
|
||||
return filters;
|
||||
}
|
||||
|
||||
std::string SearchBuilder::buildPositiveFilter() const
|
||||
{
|
||||
std::string positive_filter { "positive_filter AS (" };
|
||||
|
||||
if ( m_positive_tags.empty() )
|
||||
{
|
||||
// If there is no 'positive tags', we need to populate the positive filter with something to prevent it from returning nothing
|
||||
positive_filter += "SELECT record_id FROM file_info WHERE mime_id IS NOT NULL),";
|
||||
return positive_filter;
|
||||
}
|
||||
|
||||
for ( auto itter = m_positive_tags.begin(); itter != m_positive_tags.end(); ++itter )
|
||||
{
|
||||
positive_filter += format_ns::format( "SELECT record_id FROM filter_{}", *itter );
|
||||
|
||||
if ( itter + 1 != m_positive_tags.end() )
|
||||
positive_filter += " INTERSECT ";
|
||||
else
|
||||
positive_filter += "),";
|
||||
}
|
||||
|
||||
return positive_filter;
|
||||
}
|
||||
|
||||
std::string SearchBuilder::buildNegativeFilter() const
|
||||
{
|
||||
std::string negative_filters { "negative_filter AS (" };
|
||||
|
||||
for ( auto itter = m_negative_tags.begin(); itter != m_negative_tags.end(); ++itter )
|
||||
{
|
||||
negative_filters += format_ns::format( "SELECT record_id FROM filter_{}", *itter );
|
||||
|
||||
if ( itter + 1 != m_negative_tags.end() )
|
||||
negative_filters += " UNION DISTINCT ";
|
||||
else
|
||||
negative_filters += "),";
|
||||
}
|
||||
|
||||
return negative_filters;
|
||||
}
|
||||
|
||||
void SearchBuilder::generateOrderByClause( std::string& query ) const
|
||||
{
|
||||
switch ( m_sort_type )
|
||||
{
|
||||
// DEFAULT and HY_* should not be used here.
|
||||
default:
|
||||
[[fallthrough]];
|
||||
case SortType::FILESIZE:
|
||||
query += " ORDER BY fm.size";
|
||||
break;
|
||||
case SortType::IMPORT_TIME:
|
||||
query += " ORDER BY fm.cluster_store_time ";
|
||||
break;
|
||||
case SortType::RECORD_TIME:
|
||||
query += " ORDER BY records.creation_time ";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void SearchBuilder::determineJoinsForQuery( std::string& query )
|
||||
{
|
||||
if ( m_duration_search == DurationSearchType::HasDuration )
|
||||
{
|
||||
m_required_joins.video_metadata |= true;
|
||||
}
|
||||
|
||||
if ( m_duration_search == DurationSearchType::NoDuration )
|
||||
{
|
||||
m_required_joins.left_video_metadata |= true;
|
||||
}
|
||||
|
||||
if ( m_width_search.m_active || m_height_search.m_active )
|
||||
{
|
||||
m_required_joins.left_image_metadata |= true;
|
||||
m_required_joins.left_video_metadata |= true;
|
||||
}
|
||||
|
||||
if ( m_required_joins.left_video_metadata )
|
||||
{
|
||||
query += " LEFT JOIN video_metadata USING (record_id)";
|
||||
}
|
||||
|
||||
if ( m_required_joins.video_metadata && !m_required_joins.left_video_metadata )
|
||||
{
|
||||
query += " JOIN video_metadata USING (record_id)";
|
||||
}
|
||||
|
||||
if ( m_required_joins.left_image_metadata )
|
||||
{
|
||||
query += " LEFT JOIN image_metadata USING (record_id)";
|
||||
}
|
||||
|
||||
if ( m_required_joins.image_metadata && !m_required_joins.left_image_metadata )
|
||||
{
|
||||
query += " JOIN image_metadata USING (record_id)";
|
||||
}
|
||||
|
||||
// determine any joins needed
|
||||
if ( m_required_joins.records )
|
||||
{
|
||||
query += " JOIN records rc USING (record_id)";
|
||||
}
|
||||
|
||||
if ( m_required_joins.file_info )
|
||||
{
|
||||
query += " JOIN file_info fm USING (record_id)";
|
||||
}
|
||||
}
|
||||
|
||||
void SearchBuilder::determineSelectClause( std::string& query, const bool return_ids, const bool return_hashes )
|
||||
{
|
||||
// determine the SELECT
|
||||
if ( return_ids && return_hashes )
|
||||
{
|
||||
@@ -84,40 +216,139 @@ std::string SearchBuilder::construct(
|
||||
constexpr std::string_view select_record_id { " SELECT tm.record_id FROM final_filter tm" };
|
||||
query += select_record_id;
|
||||
}
|
||||
}
|
||||
|
||||
// determine any joins needed
|
||||
if ( m_required_joins.records && false )
|
||||
void SearchBuilder::generateWhereClauses( std::string& query )
|
||||
{
|
||||
// These are added after the join clauses
|
||||
/*
|
||||
// Not needed due to the JOIN being a filter
|
||||
if ( m_duration_search == DurationSearchType::HasDuration )
|
||||
{
|
||||
query += " JOIN records rc ON rc.record_id = tm.record_id";
|
||||
query += " WHERE vm_hd.duration IS NOT NULL";
|
||||
}
|
||||
*/
|
||||
|
||||
if ( m_duration_search == DurationSearchType::NoDuration )
|
||||
{
|
||||
query += " AND video_metadata.duration IS NULL";
|
||||
}
|
||||
|
||||
if ( m_required_joins.file_info )
|
||||
if ( m_height_search.m_active )
|
||||
{
|
||||
query += " JOIN file_info fm ON fm.record_id = tm.record_id";
|
||||
const auto operation { m_height_search.operation };
|
||||
if ( operation & SearchOperationFlags::Not )
|
||||
query += " AND NOT";
|
||||
else
|
||||
query += " AND";
|
||||
|
||||
query += " COALESCE(image_metadata.height, video_metadata.height) ";
|
||||
|
||||
if ( operation & SearchOperationFlags::Equal )
|
||||
{
|
||||
query += "= ";
|
||||
}
|
||||
|
||||
if ( operation & SearchOperationFlags::GreaterThan )
|
||||
{
|
||||
query += "> ";
|
||||
}
|
||||
else if ( operation & SearchOperationFlags::LessThan )
|
||||
{
|
||||
query += "< ";
|
||||
}
|
||||
|
||||
query += std::to_string( m_height_search.count );
|
||||
}
|
||||
|
||||
switch ( m_sort_type )
|
||||
if ( m_width_search.m_active )
|
||||
{
|
||||
// DEFAULT and HY_* should not be used here.
|
||||
default:
|
||||
[[fallthrough]];
|
||||
case SortType::FILESIZE:
|
||||
query += " ORDER BY fm.size";
|
||||
break;
|
||||
case SortType::IMPORT_TIME:
|
||||
query += " ORDER BY fm.cluster_store_time ";
|
||||
break;
|
||||
case SortType::RECORD_TIME:
|
||||
query += " ORDER BY records.creation_time ";
|
||||
break;
|
||||
const auto operation { m_width_search.operation };
|
||||
if ( operation & SearchOperationFlags::Not )
|
||||
query += " AND NOT";
|
||||
else
|
||||
query += " AND";
|
||||
|
||||
query += " COALESCE(image_metadata.width, video_metadata.width) ";
|
||||
|
||||
if ( operation & SearchOperationFlags::Equal )
|
||||
{
|
||||
query += "= ";
|
||||
}
|
||||
|
||||
if ( operation & SearchOperationFlags::GreaterThan )
|
||||
{
|
||||
query += "> ";
|
||||
}
|
||||
else if ( operation & SearchOperationFlags::LessThan )
|
||||
{
|
||||
query += "< ";
|
||||
}
|
||||
|
||||
query += std::to_string( m_width_search.count );
|
||||
}
|
||||
}
|
||||
|
||||
std::string SearchBuilder::construct( const bool return_ids, const bool return_hashes, const bool filter_domains )
|
||||
{
|
||||
// TODO: Sort tag ids to get the most out of each filter.
|
||||
|
||||
std::string query { "WITH " };
|
||||
query.reserve( 1024 );
|
||||
|
||||
if ( m_positive_tags.empty() && m_negative_tags.empty() )
|
||||
{
|
||||
// return "SELECT record_id FROM file_info WHERE mime_id IS NOT NULL";
|
||||
}
|
||||
|
||||
std::vector< TagID > filtered_tags {};
|
||||
filtered_tags.reserve( 16 );
|
||||
std::ranges::copy( m_positive_tags, std::back_inserter( filtered_tags ) );
|
||||
std::ranges::copy( m_negative_tags, std::back_inserter( filtered_tags ) );
|
||||
const auto filter_map { createFilters( filtered_tags, filter_domains ) };
|
||||
const auto positive_filter { buildPositiveFilter() };
|
||||
const auto negative_filter { buildNegativeFilter() };
|
||||
|
||||
std::string final_filter {};
|
||||
|
||||
if ( m_negative_tags.size() > 0 )
|
||||
{
|
||||
final_filter +=
|
||||
"final_filter AS (SELECT record_id FROM positive_filter EXCEPT SELECT record_id FROM negative_filter)";
|
||||
}
|
||||
else
|
||||
{
|
||||
final_filter += "final_filter AS (SELECT DISTINCT record_id FROM positive_filter)";
|
||||
}
|
||||
|
||||
m_bind_domains = filter_domains;
|
||||
|
||||
for ( const auto& filter : filter_map | std::views::values )
|
||||
{
|
||||
query += filter + ",";
|
||||
}
|
||||
query += positive_filter;
|
||||
if ( m_negative_tags.size() > 0 ) query += negative_filter;
|
||||
query += final_filter;
|
||||
|
||||
log::info( "{}", query );
|
||||
|
||||
determineSelectClause( query, return_ids, return_hashes );
|
||||
|
||||
determineJoinsForQuery( query );
|
||||
|
||||
query += " WHERE fm.mime_id IS NOT NULL";
|
||||
|
||||
generateWhereClauses( query );
|
||||
|
||||
generateOrderByClause( query );
|
||||
|
||||
query += ( m_order == SortOrder::ASC ? " ASC" : " DESC" );
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
SearchBuilder::SearchBuilder() : m_sort_type(), m_order(), m_tags(), m_display_mode()
|
||||
SearchBuilder::SearchBuilder() : m_sort_type(), m_order(), m_positive_tags(), m_display_mode()
|
||||
{}
|
||||
|
||||
drogon::Task< drogon::orm::Result > SearchBuilder::query(
|
||||
@@ -179,9 +410,252 @@ void SearchBuilder::addFileDomain( [[maybe_unused]] const FileDomainID value )
|
||||
FGL_UNIMPLEMENTED();
|
||||
}
|
||||
|
||||
void SearchBuilder::setTags( const std::vector< TagID >& vector )
|
||||
ExpectedTask< void > SearchBuilder::setTags( const std::vector< std::string >& tags )
|
||||
{
|
||||
m_tags = std::move( vector );
|
||||
std::vector< std::string > positive_tags {};
|
||||
std::vector< std::string > negative_tags {};
|
||||
|
||||
for ( const auto& tag : tags )
|
||||
{
|
||||
if ( tag.starts_with( "-" ) )
|
||||
negative_tags.push_back( tag.substr( 1 ) );
|
||||
else
|
||||
positive_tags.push_back( tag );
|
||||
}
|
||||
|
||||
auto db { drogon::app().getDbClient() };
|
||||
|
||||
const auto positive_map { co_await mapTags( positive_tags, db ) };
|
||||
const auto negative_map { co_await mapTags( negative_tags, db ) };
|
||||
return_unexpected_error( positive_map );
|
||||
return_unexpected_error( negative_map );
|
||||
|
||||
std::vector< TagID > positive_ids {};
|
||||
for ( const auto& tag_id : *positive_map | std::views::values ) positive_ids.emplace_back( tag_id );
|
||||
std::vector< TagID > negative_ids {};
|
||||
for ( const auto& tag_id : *negative_map | std::views::values ) negative_ids.emplace_back( tag_id );
|
||||
|
||||
setPositiveTags( positive_ids );
|
||||
setNegativeTags( negative_ids );
|
||||
|
||||
co_return {};
|
||||
}
|
||||
|
||||
void SearchBuilder::setPositiveTags( const std::vector< TagID >& vector )
|
||||
{
|
||||
m_positive_tags = vector;
|
||||
}
|
||||
|
||||
void SearchBuilder::setNegativeTags( const std::vector< TagID >& tag_ids )
|
||||
{
|
||||
m_negative_tags = tag_ids;
|
||||
}
|
||||
|
||||
void SearchBuilder::setSystemTags( const std::vector< std::string >& vector )
|
||||
{
|
||||
log::debug( "Got {} system tags", vector.size() );
|
||||
for ( const auto& tag : vector )
|
||||
{
|
||||
constexpr auto system_namespace { "system:" };
|
||||
constexpr auto system_namespace_len { 7 };
|
||||
if ( !tag.starts_with( system_namespace ) )
|
||||
throw std::invalid_argument( format_ns::format( "Invalid system namespace: {}", tag ) );
|
||||
|
||||
const std::string_view system_subtag { std::string_view { tag }.substr( system_namespace_len ) };
|
||||
|
||||
log::debug( "Got system tag \'{}\'", system_subtag );
|
||||
|
||||
// system:everything
|
||||
if ( system_subtag == "everything" )
|
||||
{
|
||||
m_search_everything = true;
|
||||
continue;
|
||||
}
|
||||
// system:inbox
|
||||
// system:archive
|
||||
// system:has duration
|
||||
if ( system_subtag == "has duration" )
|
||||
{
|
||||
m_duration_search = DurationSearchType::HasDuration;
|
||||
continue;
|
||||
}
|
||||
// system:no duration
|
||||
if ( system_subtag == "no duration" )
|
||||
{
|
||||
m_duration_search = DurationSearchType::NoDuration;
|
||||
continue;
|
||||
}
|
||||
// system:is the best quality file of its duplicate group
|
||||
// system:is not the best quality file of its duplicate group
|
||||
// system:has audio
|
||||
if ( system_subtag == "has audio" )
|
||||
{
|
||||
m_audio_search = AudioSearchType::HasAudio;
|
||||
continue;
|
||||
}
|
||||
// system:no audio
|
||||
if ( system_subtag == "no audio" )
|
||||
{
|
||||
m_audio_search = AudioSearchType::NoAudio;
|
||||
continue;
|
||||
}
|
||||
// system:has exif
|
||||
if ( system_subtag == "has exif" )
|
||||
{
|
||||
m_exif_search = ExitSearchType::HasExif;
|
||||
continue;
|
||||
}
|
||||
// system:no exif
|
||||
if ( system_subtag == "no exif" )
|
||||
{
|
||||
m_exif_search = ExitSearchType::NoExif;
|
||||
continue;
|
||||
}
|
||||
// system:has embedded metadata
|
||||
// system:no embedded metadata
|
||||
// system:has icc profile
|
||||
// system:no icc profile
|
||||
// system:has tags
|
||||
if ( system_subtag == "has tags" )
|
||||
{
|
||||
m_has_tags_search = TagCountSearchType::HasTags;
|
||||
continue;
|
||||
}
|
||||
// system:no tags // system:untagged // MERGED
|
||||
if ( ( system_subtag == "no tags" ) || ( system_subtag == "untagged" ) )
|
||||
{
|
||||
m_has_tags_search = TagCountSearchType::NoTags;
|
||||
continue;
|
||||
}
|
||||
// system:number of tags > 5 // system:number of tags ~= 10 // system:number of tags > 0
|
||||
if ( system_subtag.starts_with( "number of tags" ) )
|
||||
{
|
||||
parseRangeSearch( m_tag_count_search, system_subtag );
|
||||
continue;
|
||||
}
|
||||
|
||||
// system:number of words < 2
|
||||
// system:height = 600 // system:height > 900
|
||||
if ( system_subtag.starts_with( "height" ) )
|
||||
{
|
||||
parseRangeSearch( m_height_search, system_subtag );
|
||||
continue;
|
||||
}
|
||||
// system:width < 200 // system:width > 1000
|
||||
if ( system_subtag.starts_with( "width" ) )
|
||||
{
|
||||
parseRangeSearch( m_width_search, system_subtag );
|
||||
continue;
|
||||
}
|
||||
// system:filesize ~= 50 kilobytes // system:filesize > 10megabytes // system:filesize < 1 GB // system:filesize > 0 B
|
||||
if ( system_subtag.starts_with( "filesize" ) )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// system:similar to abcdef01 abcdef02 abcdef03, abcdef04 with distance 3
|
||||
// system:similar to abcdef distance 5
|
||||
// system:limit = 100
|
||||
if ( system_subtag.starts_with( "limit" ) )
|
||||
{
|
||||
parseRangeSearch( m_limit_search, system_subtag );
|
||||
continue;
|
||||
}
|
||||
// system:filetype = image/jpg, image/png, apng
|
||||
if ( system_subtag.starts_with( "filetype" ) )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// system:hash = abcdef01 abcdef02 abcdef03 (this does sha256)
|
||||
// system:hash = abcdef01 abcdef02 md5
|
||||
if ( system_subtag.starts_with( "hash" ) )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// system:modified date < 7 years 45 days 7h // system:modified date > 2011-06-04
|
||||
// system:date modified > 7 years 2 months // system:date modified < 0 years 1 month 1 day 1 hour
|
||||
if ( system_subtag.starts_with( "modified date" ) || system_subtag.starts_with( "date modified" ) )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// system:last viewed time < 7 years 45 days 7h
|
||||
// system:last view time < 7 years 45 days 7h
|
||||
// system:import time < 7 years 45 days 7h
|
||||
// system:time imported < 7 years 45 days 7h // system:time imported > 2011-06-04
|
||||
// system:time imported > 7 years 2 months // system:time imported < 0 years 1 month 1 day 1 hour
|
||||
// system:time imported ~= 2011-1-3 // system:time imported ~= 1996-05-2
|
||||
if ( system_subtag.starts_with( "time imported" ) )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// system:duration < 5 seconds
|
||||
// system:duration ~= 600 msecs
|
||||
// system:duration > 3 milliseconds
|
||||
// system:file service is pending to my files
|
||||
// system:file service currently in my files
|
||||
// system:file service is not currently in my files
|
||||
// system:file service is not pending to my files
|
||||
// system:number of file relationships = 2 duplicates
|
||||
// system:number of file relationships > 10 potential duplicates
|
||||
// system:num file relationships < 3 alternates
|
||||
// system:num file relationships > 3 false positives
|
||||
// system:ratio is wider than 16:9
|
||||
if ( system_subtag.starts_with( "ratio wider than" ) )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// system:ratio is 16:9
|
||||
if ( system_subtag.starts_with( "ratio is" ) )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// system:ratio taller than 1:1
|
||||
if ( system_subtag.starts_with( "ratio taller than" ) )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// system:num pixels > 50 px // system:num pixels < 1 megapixels // system:num pixels ~= 5 kilopixel
|
||||
if ( system_subtag.starts_with( "num pixels" ) )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// system:views in media ~= 10
|
||||
// system:views in preview < 10
|
||||
// system:views > 0
|
||||
// system:viewtime in client api < 1 days 1 hour 0 minutes
|
||||
// system:viewtime in media, client api, preview ~= 1 day 30 hours 100 minutes 90s
|
||||
// system:has url matching regex index\.php
|
||||
// system:does not have a url matching regex index\.php
|
||||
// system:has url https://somebooru.org/posts/123456
|
||||
if ( system_subtag.starts_with( "has url" ) )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// system:does not have url https://somebooru.org/posts/123456
|
||||
if ( system_subtag.starts_with( "does not have url" ) )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// system:has domain safebooru.com
|
||||
// system:does not have domain safebooru.com
|
||||
// system:has a url with class safebooru file page
|
||||
// system:does not have a url with url class safebooru file page
|
||||
// system:tag as number page < 5
|
||||
// system:has notes
|
||||
// system:no notes
|
||||
// system:does not have notes
|
||||
// system:num notes is 5
|
||||
// system:num notes > 1
|
||||
// system:has note with name note name
|
||||
// system:no note with name note name
|
||||
// system:does not have note with name note name
|
||||
// system:has a rating for service_name
|
||||
// system:does not have a rating for service_name
|
||||
// system:rating for service_name > ⅗ (numerical services)
|
||||
// system:rating for service_name is like (like/dislike services)
|
||||
// system:rating for service_name = 13 (inc/dec services)
|
||||
|
||||
log::warn( "Unsupported system tag system: \'{}\'", system_subtag );
|
||||
}
|
||||
}
|
||||
|
||||
void SearchBuilder::setDisplay( const HydrusDisplayType type )
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
//
|
||||
#pragma once
|
||||
|
||||
#include <expected>
|
||||
#include <string_view>
|
||||
|
||||
#include "IDHANTypes.hpp"
|
||||
#include "SearchBuilder.hpp"
|
||||
#include "api/APIAuth.hpp"
|
||||
#include "db/dbTypes.hpp"
|
||||
#include "drogon/HttpRequest.h"
|
||||
#include "drogon/orm/DbClient.h"
|
||||
@@ -156,15 +158,103 @@ class SearchBuilder
|
||||
{
|
||||
bool file_info { false };
|
||||
bool records { false };
|
||||
|
||||
bool left_video_metadata { false };
|
||||
bool video_metadata { false };
|
||||
|
||||
bool left_image_metadata { false };
|
||||
bool image_metadata { false };
|
||||
} m_required_joins {};
|
||||
|
||||
bool m_search_everything { false };
|
||||
|
||||
using SearchOperation = std::uint8_t;
|
||||
|
||||
enum SearchOperationFlags : SearchOperation
|
||||
{
|
||||
GreaterThan = 1 << 0, // >
|
||||
LessThan = 1 << 1, // <
|
||||
Equal = 1 << 2, // =
|
||||
Not = 1 << 3, // !
|
||||
// Approximate = 1 << 4, // ~
|
||||
Approximate = Equal, // ~
|
||||
|
||||
// helpers
|
||||
NotLessThan = Not | LessThan, // ~<
|
||||
NotGreaterThan = Not | GreaterThan, // ~>
|
||||
|
||||
GreaterThanEqual = GreaterThan | Equal, // >=
|
||||
LessThanEqual = LessThan | Equal, // <=
|
||||
NotGreaterThanEqual = Not | GreaterThanEqual, // ~>=
|
||||
NotLessThanEqual = Not | LessThanEqual, // ~<=
|
||||
|
||||
NotEqual = Not | Equal, // ~=
|
||||
};
|
||||
|
||||
enum class DurationSearchType
|
||||
{
|
||||
DontCare = 0,
|
||||
HasDuration,
|
||||
NoDuration
|
||||
} m_duration_search { DurationSearchType::DontCare };
|
||||
|
||||
enum class AudioSearchType
|
||||
{
|
||||
DontCare = 0,
|
||||
HasAudio,
|
||||
NoAudio
|
||||
} m_audio_search { AudioSearchType::DontCare };
|
||||
|
||||
enum class ExitSearchType
|
||||
{
|
||||
DontCare = 0,
|
||||
HasExif,
|
||||
NoExif
|
||||
} m_exif_search { ExitSearchType::DontCare };
|
||||
|
||||
enum class TagCountSearchType
|
||||
{
|
||||
DontCare = 0,
|
||||
HasTags,
|
||||
NoTags,
|
||||
HasCount
|
||||
} m_has_tags_search { TagCountSearchType::DontCare };
|
||||
|
||||
struct RangeSearchInfo
|
||||
{
|
||||
//! If true then this count and operation are put into effect
|
||||
bool m_active { false };
|
||||
std::size_t count { 0 };
|
||||
SearchOperation operation { 0 };
|
||||
};
|
||||
|
||||
static void parseRangeSearch( RangeSearchInfo& target, std::string_view tag );
|
||||
|
||||
RangeSearchInfo m_tag_count_search {};
|
||||
|
||||
RangeSearchInfo m_width_search {};
|
||||
RangeSearchInfo m_height_search {};
|
||||
|
||||
RangeSearchInfo m_limit_search {};
|
||||
|
||||
SortType m_sort_type;
|
||||
SortOrder m_order;
|
||||
std::vector< TagID > m_tags;
|
||||
std::vector< TagID > m_positive_tags;
|
||||
std::vector< TagID > m_negative_tags {};
|
||||
|
||||
HydrusDisplayType m_display_mode;
|
||||
bool m_bind_domains { false };
|
||||
|
||||
static std::unordered_map< TagID, std::string > createFilters(
|
||||
const std::vector< TagID >& tag_ids,
|
||||
bool filter_domains );
|
||||
std::string buildPositiveFilter() const;
|
||||
std::string buildNegativeFilter() const;
|
||||
|
||||
void generateOrderByClause( std::string& query ) const;
|
||||
void determineJoinsForQuery( std::string& query );
|
||||
void determineSelectClause( std::string& query, bool return_ids, bool return_hashes );
|
||||
void generateWhereClauses( std::string& query );
|
||||
/**
|
||||
* @brief Constructs a query to be used. $1 is expected to be an array of tag_domain_ids
|
||||
* @param return_ids
|
||||
@@ -191,8 +281,12 @@ class SearchBuilder
|
||||
void filterTagDomain( TagDomainID value );
|
||||
|
||||
void addFileDomain( FileDomainID value );
|
||||
drogon::Task< std::expected< void, std::shared_ptr< drogon::HttpResponse > > > setTags(
|
||||
const std::vector< std::string >& tags );
|
||||
|
||||
void setTags( const std::vector< TagID >& vector );
|
||||
void setPositiveTags( const std::vector< TagID >& vector );
|
||||
void setNegativeTags( const std::vector< TagID >& tag_ids );
|
||||
void setSystemTags( const std::vector< std::string >& vector );
|
||||
|
||||
void setDisplay( HydrusDisplayType type );
|
||||
};
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
#include <fstream>
|
||||
#include <istream>
|
||||
|
||||
#include "../filesystem/io/IOUring.hpp"
|
||||
#include "api/helpers/createBadRequest.hpp"
|
||||
#include "decodeHex.hpp"
|
||||
#include "fgl/defines.hpp"
|
||||
#include "filesystem/IOUring.hpp"
|
||||
|
||||
namespace idhan
|
||||
{
|
||||
@@ -29,6 +29,11 @@ SHA256::SHA256( const std::string_view& data ) : m_data()
|
||||
|
||||
SHA256::SHA256( const drogon::orm::Field& field )
|
||||
{
|
||||
if ( field.isNull() )
|
||||
{
|
||||
throw std::invalid_argument( "Field is null" );
|
||||
}
|
||||
|
||||
const auto data { field.as< std::vector< char > >() };
|
||||
|
||||
FGL_ASSERT(
|
||||
@@ -42,8 +47,7 @@ std::string SHA256::hex() const
|
||||
{
|
||||
std::string str {};
|
||||
str.reserve( m_data.size() );
|
||||
for ( std::size_t i = 0; i < m_data.size(); ++i )
|
||||
str += format_ns::format( "{:02x}", static_cast< std::uint8_t >( m_data[ i ] ) );
|
||||
for ( auto i : m_data ) str += format_ns::format( "{:02x}", static_cast< std::uint8_t >( i ) );
|
||||
return str;
|
||||
}
|
||||
|
||||
@@ -138,17 +142,29 @@ SHA256 SHA256::hash( const std::byte* data, const std::size_t size )
|
||||
|
||||
drogon::Task< SHA256 > SHA256::hashCoro( FileIOUring io_uring )
|
||||
{
|
||||
const auto data { co_await io_uring.readAll() };
|
||||
QCryptographicHash hasher { QCryptographicHash::Sha256 };
|
||||
|
||||
if ( data.empty() )
|
||||
constexpr auto block_size { 1024 * 1024 };
|
||||
|
||||
for ( std::size_t i = 0; i < io_uring.size(); i += block_size )
|
||||
{
|
||||
log::warn(
|
||||
"While reading file {}, The filesystem said the file was zero bytes, or the read failed! A following "
|
||||
"warning might occur!",
|
||||
io_uring.path().string() );
|
||||
const auto data { co_await io_uring.read( i, block_size ) };
|
||||
|
||||
const QByteArrayView view { data.data(), static_cast< qsizetype >( data.size() ) };
|
||||
|
||||
hasher.addData( view );
|
||||
}
|
||||
|
||||
co_return hash( data.data(), data.size() );
|
||||
const auto result { hasher.result() };
|
||||
|
||||
std::vector< std::byte > out_data {};
|
||||
out_data.resize( 256 / 8 );
|
||||
|
||||
FGL_ASSERT( out_data.size() == static_cast< std::size_t >( result.size() ), "Invalid size" );
|
||||
|
||||
std::memcpy( out_data.data(), result.data(), static_cast< std::size_t >( result.size() ) );
|
||||
|
||||
co_return SHA256::fromBuffer( out_data );
|
||||
}
|
||||
|
||||
} // namespace idhan
|
||||
|
||||
@@ -35,8 +35,6 @@ class SHA256
|
||||
{
|
||||
std::array< std::byte, ( 256 / 8 ) > m_data {};
|
||||
|
||||
SHA256() = delete;
|
||||
|
||||
explicit SHA256( const std::byte* data );
|
||||
SHA256( const std::string_view& data );
|
||||
|
||||
@@ -48,7 +46,7 @@ class SHA256
|
||||
|
||||
public:
|
||||
|
||||
static constexpr std::size_t size() { return ( 256 / 8 ); }
|
||||
SHA256() = default;
|
||||
|
||||
SHA256( const drogon::orm::Field& field );
|
||||
|
||||
@@ -58,6 +56,8 @@ class SHA256
|
||||
SHA256& operator=( SHA256&& other ) = default;
|
||||
SHA256( SHA256&& other ) = default;
|
||||
|
||||
static constexpr std::size_t size() { return ( 256 / 8 ); }
|
||||
|
||||
std::array< std::byte, ( 256 / 8 ) > data() const { return m_data; }
|
||||
|
||||
//! Supplied so we can work with drogon until I figure out how the fuck to overload their operators.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
#include <expected>
|
||||
|
||||
#include "api/helpers/ExpectedTask.hpp"
|
||||
#include "threading/ExpectedTask.hpp"
|
||||
#include "api/helpers/createBadRequest.hpp"
|
||||
#include "drogon/HttpAppFramework.h"
|
||||
#include "logging/log.hpp"
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
#include <expected>
|
||||
#include <vector>
|
||||
|
||||
#include "threading/ExpectedTask.hpp"
|
||||
#include "IDHANTypes.hpp"
|
||||
#include "api/helpers/ExpectedTask.hpp"
|
||||
#include "dbTypes.hpp"
|
||||
#include "drogon/HttpAppFramework.h"
|
||||
#include "drogon/orm/BaseBuilder.h"
|
||||
|
||||
@@ -159,6 +159,43 @@ std::vector< std::byte > createPgBinaryArray( std::vector< std::string >&& strin
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector< std::byte > createPgBinaryArray( const std::vector< std::string >&& strings )
|
||||
{
|
||||
std::vector< std::byte > result {};
|
||||
|
||||
std::size_t string_sizes { 0 };
|
||||
for ( const auto& str : strings ) string_sizes += str.size();
|
||||
|
||||
struct [[gnu::packed]] Element
|
||||
{
|
||||
std::uint32_t element_length { 0 };
|
||||
};
|
||||
|
||||
static_assert( sizeof( Element ) == sizeof( std::uint32_t ), "Element is not sized properly" );
|
||||
|
||||
result.resize( sizeof( Header ) + ( sizeof( Element ) * strings.size() ) + string_sizes );
|
||||
std::memset( result.data(), 0xFF, result.size() );
|
||||
|
||||
auto* header = reinterpret_cast< Header* >( result.data() );
|
||||
header->num_dimensions = htonl( 1 ); // dimension count
|
||||
header->data_offset = htonl( 0 ); // any nulls?
|
||||
header->element_type_oid = htonl( OID_TEXT ); // element type
|
||||
header->dimension_length = htonl( static_cast< uint32_t >( strings.size() ) ); // size of first dimension
|
||||
header->lower_bound = htonl( 1 ); // offset of first dimension
|
||||
|
||||
std::byte* ptr = result.data() + sizeof( Header );
|
||||
for ( const auto& str : strings )
|
||||
{
|
||||
auto& element = *reinterpret_cast< Element* >( ptr );
|
||||
// const auto filtered_string { idhan::api::helpers::pgEscape( str ) };
|
||||
element.element_length = htonl( static_cast< std::uint32_t >( str.size() ) );
|
||||
std::memcpy( ptr + sizeof( Element ), str.data(), str.size() );
|
||||
ptr += sizeof( Element ) + str.size();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector< std::byte > createPgBinaryArray( std::set< std::string >&& strings )
|
||||
{
|
||||
std::vector< std::byte > result {};
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
#include "fgl/defines.hpp"
|
||||
#include "logging/format_ns.hpp"
|
||||
|
||||
namespace idhan::api::helpers
|
||||
namespace idhan::helpers
|
||||
{
|
||||
|
||||
constexpr std::string pgEscapeI( const std::string& str )
|
||||
@@ -57,4 +57,4 @@ FGL_FLATTEN std::string pgEscape( const std::string& str )
|
||||
return pgEscapeI( str );
|
||||
}
|
||||
|
||||
} // namespace idhan::api::helpers
|
||||
} // namespace idhan::helpers
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace idhan::api::helpers
|
||||
namespace idhan::helpers
|
||||
{
|
||||
std::string pgEscape( const std::string& s );
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
@startuml
|
||||
'https://plantuml.com/sequence-diagram
|
||||
|
||||
autonumber
|
||||
|
||||
|
||||
participant Client
|
||||
|
||||
!pragma teoz true
|
||||
box "Server" #LightBlue
|
||||
participant Server
|
||||
box "Internal"
|
||||
queue ImporterQueue as Queue
|
||||
participant Importer...N as Importer
|
||||
participant MimeDatabase
|
||||
end box
|
||||
|
||||
end box
|
||||
|
||||
activate Client
|
||||
activate Server
|
||||
|
||||
Client <-> Server: Auth
|
||||
|
||||
Client -> Server: Submit file
|
||||
Server -> Server: Generate import UID
|
||||
|
||||
Server -> Server: Wait for import queue to have space available
|
||||
Server -> Queue: Place file onto queue
|
||||
|
||||
Server -> Client: Return UID
|
||||
|
||||
deactivate Client
|
||||
|
||||
Queue <-> Importer: Pull next import task
|
||||
Importer -> MimeDatabase: Request mime parse
|
||||
|
||||
MimeDatabase -> Importer: Return mime info
|
||||
|
||||
alt#Gold #LightBlue Mime detected
|
||||
Importer -> Importer: Continue import
|
||||
else #Pink Mime Unknown
|
||||
Importer -> Server: Invalidate UID
|
||||
end
|
||||
|
||||
== ==
|
||||
|
||||
autonumber
|
||||
|
||||
Client -> Server: Request import info
|
||||
alt#Gold #LightBlue Import success
|
||||
Server -> Client: Returns
|
||||
else #Pink UID Invalid/Error
|
||||
Server -> Client: Returns error
|
||||
else #Grey Import not completed
|
||||
Server -> Server: Wait for import to completed
|
||||
Server -> Client:
|
||||
end
|
||||
|
||||
|
||||
destroy Client
|
||||
|
||||
@enduml
|
||||
@@ -3,15 +3,15 @@
|
||||
//
|
||||
|
||||
#include "IDHANTypes.hpp"
|
||||
#include "api/helpers/ExpectedTask.hpp"
|
||||
#include "db/dbTypes.hpp"
|
||||
#include "utility.hpp"
|
||||
#include "filesystem.hpp"
|
||||
#include "threading/ExpectedTask.hpp"
|
||||
|
||||
namespace idhan::filesystem
|
||||
{
|
||||
ExpectedTask< bool > checkFileExists( const RecordID record_id, DbClientPtr db )
|
||||
{
|
||||
const auto file_path_e { co_await getFilepath( record_id, db ) };
|
||||
const auto file_path_e { co_await getRecordPath( record_id, db ) };
|
||||
return_unexpected_error( file_path_e );
|
||||
|
||||
co_return std::filesystem::exists( *file_path_e );
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
#include <filesystem>
|
||||
#include <future>
|
||||
|
||||
#include "../io/IOUring.hpp"
|
||||
#include "IDHANTypes.hpp"
|
||||
#include "IOUring.hpp"
|
||||
#include "api/helpers/ExpectedTask.hpp"
|
||||
#include "db/dbTypes.hpp"
|
||||
#include "threading/ExpectedTask.hpp"
|
||||
|
||||
namespace idhan
|
||||
{
|
||||
@@ -1,20 +1,32 @@
|
||||
//
|
||||
// Created by kj16609 on 10/30/25.
|
||||
// Created by kj16609 on 11/13/25.
|
||||
//
|
||||
#pragma once
|
||||
#include "IDHANTypes.hpp"
|
||||
#include "api/helpers/ExpectedTask.hpp"
|
||||
#include "crypto/SHA256.hpp"
|
||||
#include <filesystem>
|
||||
|
||||
#include "io/IOUring.hpp"
|
||||
#include "threading/ExpectedTask.hpp"
|
||||
|
||||
namespace idhan
|
||||
{
|
||||
class SHA256;
|
||||
}
|
||||
|
||||
namespace idhan::filesystem
|
||||
{
|
||||
|
||||
std::filesystem::path getFileFolder( const SHA256& sha256 );
|
||||
|
||||
/**
|
||||
*
|
||||
* @param record_id Record of which to get a filepath for
|
||||
* @param db
|
||||
* @return
|
||||
*/
|
||||
ExpectedTask< std::filesystem::path > getFilepath( RecordID record_id, DbClientPtr db );
|
||||
ExpectedTask< std::filesystem::path > getRecordPath( RecordID record_id, DbClientPtr db );
|
||||
|
||||
//! Returns a FileIOUring instance for the given record
|
||||
ExpectedTask< FileIOUring > getIOForRecord( RecordID record_id, DbClientPtr db );
|
||||
|
||||
//! Returns the path of a cluster.
|
||||
ExpectedTask< std::filesystem::path > getClusterPath( ClusterID cluster_id );
|
||||
@@ -57,4 +69,4 @@ ExpectedTask< FileState > validateFile( RecordID record_id );
|
||||
*/
|
||||
std::int64_t getLastWriteTime( const std::filesystem::path& path );
|
||||
|
||||
} // namespace idhan::filesystem
|
||||
} // namespace idhan::filesystem
|
||||
@@ -2,9 +2,9 @@
|
||||
// Created by kj16609 on 10/30/25.
|
||||
//
|
||||
|
||||
#include "ClusterManager.hpp"
|
||||
#include "IDHANTypes.hpp"
|
||||
#include "api/helpers/ExpectedTask.hpp"
|
||||
#include "clusters/ClusterManager.hpp"
|
||||
#include "threading/ExpectedTask.hpp"
|
||||
|
||||
namespace idhan::filesystem
|
||||
{
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
//
|
||||
// Created by kj16609 on 10/30/25.
|
||||
//
|
||||
|
||||
#include "IDHANTypes.hpp"
|
||||
#include "api/helpers/ExpectedTask.hpp"
|
||||
#include "api/helpers/createBadRequest.hpp"
|
||||
#include "crypto/SHA256.hpp"
|
||||
#include "drogon/HttpAppFramework.h"
|
||||
|
||||
namespace idhan::filesystem
|
||||
{
|
||||
|
||||
ExpectedTask< std::filesystem::path > getFilepath( const RecordID record_id, DbClientPtr db )
|
||||
{
|
||||
constexpr auto query { R"(
|
||||
SELECT sha256, best_extension, extension, folder_path FROM records
|
||||
LEFT JOIN file_info ON records.record_id = file_info.record_id
|
||||
LEFT JOIN mime ON file_info.mime_id = mime.mime_id
|
||||
LEFT JOIN file_clusters ON file_info.cluster_id = file_clusters.cluster_id
|
||||
WHERE records.record_id = $1
|
||||
)" };
|
||||
|
||||
const auto result { co_await db->execSqlCoro( query, record_id ) };
|
||||
if ( result.empty() ) co_return std::unexpected( createBadRequest( "Invalid or Unstored record ID" ) );
|
||||
|
||||
const auto& row { result[ 0 ] };
|
||||
const SHA256 hash { row[ "sha256" ] };
|
||||
if ( row[ "folder_path" ].isNull() )
|
||||
co_return std::unexpected( createNotFound( "Record {} has no files stored", record_id ) );
|
||||
|
||||
const auto cluster_path { row[ "folder_path" ].as< std::string >() };
|
||||
|
||||
const auto folder_path { row[ "folder_path" ].as< std::string >() };
|
||||
|
||||
auto extension { row[ "best_extension" ].isNull() ?
|
||||
row[ "extension" ].as< std::string >() :
|
||||
row[ "best_extension" ].as< std::string >() };
|
||||
|
||||
// remove leading . if present
|
||||
if ( extension.starts_with( '.' ) ) extension = extension.substr( 1 );
|
||||
|
||||
const auto hash_string { hash.hex() };
|
||||
const std::string folder_name { format_ns::format( "f{}", hash_string.substr( 0, 2 ) ) };
|
||||
const std::string file_name { format_ns::format( "{}.{}", hash_string, extension ) };
|
||||
|
||||
std::filesystem::path path { cluster_path };
|
||||
path /= folder_name;
|
||||
path /= file_name;
|
||||
|
||||
co_return path;
|
||||
}
|
||||
|
||||
} // namespace idhan::filesystem
|
||||
30
IDHANServer/src/filesystem/getIOForRecord.cpp
Normal file
30
IDHANServer/src/filesystem/getIOForRecord.cpp
Normal file
@@ -0,0 +1,30 @@
|
||||
//
|
||||
// Created by kj16609 on 11/13/25.
|
||||
//
|
||||
|
||||
#include "IDHANTypes.hpp"
|
||||
#include "api/helpers/createBadRequest.hpp"
|
||||
#include "db/dbTypes.hpp"
|
||||
#include "filesystem.hpp"
|
||||
#include "io/IOUring.hpp"
|
||||
#include "threading/ExpectedTask.hpp"
|
||||
|
||||
namespace idhan::filesystem
|
||||
{
|
||||
|
||||
ExpectedTask< FileIOUring > getIOForRecord( const RecordID record_id, DbClientPtr db )
|
||||
{
|
||||
const auto path { co_await filesystem::getRecordPath( record_id, db ) };
|
||||
return_unexpected_error( path );
|
||||
|
||||
if ( !std::filesystem::exists( *path ) )
|
||||
{
|
||||
co_return std::unexpected(
|
||||
createInternalError( "Record {} does not exist at the expected path \'{}\'.", record_id, path->string() ) );
|
||||
}
|
||||
|
||||
FileIOUring uring { *path };
|
||||
co_return uring;
|
||||
}
|
||||
|
||||
} // namespace idhan::filesystem
|
||||
@@ -2,20 +2,27 @@
|
||||
// Created by kj16609 on 6/12/25.
|
||||
//
|
||||
|
||||
#include "createBadRequest.hpp"
|
||||
#include "api/helpers/createBadRequest.hpp"
|
||||
#include "crypto/SHA256.hpp"
|
||||
#include "drogon/utils/coroutine.h"
|
||||
#include "helpers.hpp"
|
||||
#include "threading/ExpectedTask.hpp"
|
||||
|
||||
namespace idhan::api::helpers
|
||||
namespace idhan::filesystem
|
||||
{
|
||||
drogon::Task< std::expected< std::filesystem::path, drogon::HttpResponsePtr > > getRecordPath(
|
||||
const RecordID record_id,
|
||||
DbClientPtr db )
|
||||
|
||||
std::filesystem::path getFileFolder( const SHA256& sha256 )
|
||||
{
|
||||
const auto hex { sha256.hex() };
|
||||
const auto folder_name { std::format( "f{}", hex.substr( 0, 2 ) ) };
|
||||
|
||||
return folder_name;
|
||||
}
|
||||
|
||||
ExpectedTask< std::filesystem::path > getRecordPath( const RecordID record_id, DbClientPtr db )
|
||||
{
|
||||
const auto result { co_await db->execSqlCoro(
|
||||
|
||||
R"(SELECT folder_path, sha256, COALESCE(extension, best_extension) as extension
|
||||
R"(SELECT folder_path, sha256, COALESCE(extension, best_extension, '') as extension
|
||||
FROM records
|
||||
JOIN file_info ON records.record_id = file_info.record_id
|
||||
LEFT JOIN mime ON file_info.mime_id = mime.mime_id
|
||||
@@ -31,10 +38,15 @@ drogon::Task< std::expected< std::filesystem::path, drogon::HttpResponsePtr > >
|
||||
const std::string mime_extension { result[ 0 ][ 2 ].as< std::string >() };
|
||||
|
||||
const auto hex { sha256.hex() };
|
||||
const std::filesystem::path file_location {
|
||||
folder_path / std::format( "f{}", hex.substr( 0, 2 ) ) / ( std::format( "{}.{}", hex, mime_extension ) )
|
||||
|
||||
const auto filename {
|
||||
mime_extension.empty() ? std::format( "{}", hex ) : std::format( "{}.{}", hex, mime_extension )
|
||||
};
|
||||
|
||||
const auto folder_name { getFileFolder( sha256 ) };
|
||||
|
||||
const auto file_location { folder_path / folder_name / filename };
|
||||
|
||||
co_return file_location;
|
||||
}
|
||||
} // namespace idhan::api::helpers
|
||||
} // namespace idhan::filesystem
|
||||
@@ -3,11 +3,11 @@
|
||||
//
|
||||
|
||||
#include "IDHANTypes.hpp"
|
||||
#include "api/helpers/ExpectedTask.hpp"
|
||||
#include "crypto/SHA256.hpp"
|
||||
#include "drogon/HttpAppFramework.h"
|
||||
#include "filesystem.hpp"
|
||||
#include "logging/format_ns.hpp"
|
||||
#include "utility.hpp"
|
||||
#include "threading/ExpectedTask.hpp"
|
||||
|
||||
namespace idhan::filesystem
|
||||
{
|
||||
|
||||
@@ -63,7 +63,7 @@ drogon::Task< std::vector< std::byte > > FileIOUring::readAll() const
|
||||
co_return co_await read( 0, file_size );
|
||||
}
|
||||
|
||||
drogon::Task< std::vector< std::byte > > FileIOUring::read( const std::size_t offset, const std::size_t len ) const
|
||||
drogon::Task< std::vector< std::byte > > FileIOUring::read( const std::size_t offset, std::size_t len ) const
|
||||
{
|
||||
auto& uring { IOUring::getInstance() };
|
||||
|
||||
@@ -79,6 +79,20 @@ drogon::Task< std::vector< std::byte > > FileIOUring::read( const std::size_t of
|
||||
io_uring_sqe sqe {};
|
||||
std::memset( &sqe, 0, sizeof( sqe ) );
|
||||
|
||||
const auto file_max { size() };
|
||||
|
||||
if ( offset > file_max )
|
||||
{
|
||||
// no bytes would be read because of OOB
|
||||
co_return {};
|
||||
}
|
||||
|
||||
if ( offset + len > file_max )
|
||||
{
|
||||
// clamp the length down
|
||||
len = file_max - offset;
|
||||
}
|
||||
|
||||
auto buffer_ptr { std::make_shared< std::vector< std::byte > >() };
|
||||
buffer_ptr->resize( len );
|
||||
|
||||
@@ -145,8 +159,9 @@ drogon::Task< void > FileIOUring::fallbackWrite( const std::vector< std::byte >
|
||||
co_return;
|
||||
}
|
||||
|
||||
std::pair< void*, std::size_t > FileIOUring::mmap()
|
||||
std::pair< void*, std::size_t > FileIOUring::mmapReadOnly()
|
||||
{
|
||||
if ( m_mmap_ptr ) return { m_mmap_ptr, size() };
|
||||
void* ptr = ::mmap( nullptr, size(), PROT_READ, MAP_SHARED, m_fd, 0 );
|
||||
m_mmap_ptr = ptr;
|
||||
return std::make_pair( ptr, size() );
|
||||
@@ -50,7 +50,7 @@ class [[nodiscard]] FileIOUring
|
||||
[[nodiscard]] drogon::Task< void > fallbackWrite( std::vector< std::byte > data, std::size_t size ) const;
|
||||
|
||||
//! Mmaps the file into memory, Read only
|
||||
[[nodiscard]] std::pair< void*, std::size_t > mmap();
|
||||
[[nodiscard]] std::pair< void*, std::size_t > mmapReadOnly();
|
||||
|
||||
FileIOUring() = delete;
|
||||
[[nodiscard]] FileIOUring( const FileIOUring& );
|
||||
@@ -2,8 +2,10 @@
|
||||
// Created by kj16609 on 10/30/25.
|
||||
//
|
||||
|
||||
#include "api/helpers/ExpectedTask.hpp"
|
||||
#include "utility.hpp"
|
||||
#include "IDHANTypes.hpp"
|
||||
#include "db/dbTypes.hpp"
|
||||
#include "filesystem.hpp"
|
||||
#include "threading/ExpectedTask.hpp"
|
||||
|
||||
namespace idhan::filesystem
|
||||
{
|
||||
@@ -16,6 +18,7 @@ ExpectedTask< FileState > validateFile( const RecordID record_id, DbClientPtr db
|
||||
if ( *file_exists_e ) co_return FileState::FileNotFound;
|
||||
|
||||
// TODO: Validate hash
|
||||
FGL_UNIMPLEMENTED();
|
||||
}
|
||||
|
||||
} // namespace idhan::filesystem
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
|
||||
#include "HyAPI.hpp"
|
||||
|
||||
#include "../records/records.hpp"
|
||||
#include "IDHANTypes.hpp"
|
||||
#include "api/TagAPI.hpp"
|
||||
#include "api/helpers/createBadRequest.hpp"
|
||||
#include "api/helpers/records.hpp"
|
||||
#include "api/version.hpp"
|
||||
#include "constants/hydrus_version.hpp"
|
||||
#include "core/search/SearchBuilder.hpp"
|
||||
@@ -19,7 +19,6 @@
|
||||
#include "helpers.hpp"
|
||||
#include "logging/ScopedTimer.hpp"
|
||||
#include "logging/log.hpp"
|
||||
#include "metadata/parseMetadata.hpp"
|
||||
|
||||
namespace idhan::hyapi
|
||||
{
|
||||
@@ -143,152 +142,6 @@ T getDefaultedValue( const std::string name, drogon::HttpRequestPtr request, con
|
||||
return request->getOptionalParameter< T >( name ).value_or( default_value );
|
||||
}
|
||||
|
||||
drogon::Task< drogon::HttpResponsePtr > HydrusAPI::searchFiles( drogon::HttpRequestPtr request )
|
||||
{
|
||||
logging::ScopedTimer timer { "Search files", std::chrono::seconds( 2 ) };
|
||||
const auto start = std::chrono::system_clock::now();
|
||||
const auto tags_o { request->getOptionalParameter< std::string >( "tags" ) };
|
||||
constexpr auto empty_tags { "[]" };
|
||||
const auto tags_parameter_str { tags_o.value_or( empty_tags ) };
|
||||
if ( tags_parameter_str == empty_tags )
|
||||
{
|
||||
Json::Value value;
|
||||
value[ "file_ids" ] = Json::Value( Json::arrayValue );
|
||||
|
||||
co_return drogon::HttpResponse::newHttpJsonResponse( value );
|
||||
}
|
||||
|
||||
auto db { drogon::app().getDbClient() };
|
||||
|
||||
// Build the search
|
||||
SearchBuilder builder {};
|
||||
|
||||
Json::Value tags_json {};
|
||||
Json::Reader reader;
|
||||
if ( !reader.parse( tags_parameter_str, tags_json ) )
|
||||
{
|
||||
// Try to decode the text again and re-parse it
|
||||
const auto decoded_tags { drogon::utils::urlDecode( tags_parameter_str ) };
|
||||
if ( reader.parse( decoded_tags, tags_json ) )
|
||||
{
|
||||
log::warn( "Tags JSON had to be URL-decoded a second time. Call the requester an idiot" );
|
||||
}
|
||||
else
|
||||
{
|
||||
co_return createBadRequest( "Invalid tags json: Was {}", tags_parameter_str );
|
||||
}
|
||||
}
|
||||
|
||||
std::vector< std::string > search_tags {};
|
||||
search_tags.reserve( tags_json.size() );
|
||||
|
||||
for ( const auto& tag : tags_json )
|
||||
{
|
||||
const auto tag_text { tag.asString() };
|
||||
if ( tag_text.starts_with( "system:" ) )
|
||||
{
|
||||
// TODO: Handle system tag
|
||||
continue;
|
||||
}
|
||||
|
||||
search_tags.emplace_back( tag_text );
|
||||
}
|
||||
|
||||
std::string tag_array_str {};
|
||||
tag_array_str.reserve( search_tags.size() * 128 );
|
||||
for ( std::size_t i = 0; i < search_tags.size(); ++i )
|
||||
{
|
||||
tag_array_str += format_ns::format( "\'{}\'", search_tags[ i ] );
|
||||
if ( i + 1 != search_tags.size() ) tag_array_str += ",";
|
||||
}
|
||||
const auto query {
|
||||
format_ns::format( "SELECT tag_id, tag_text FROM tags WHERE tag_text = ANY(ARRAY[{}]::TEXT[])", tag_array_str )
|
||||
};
|
||||
|
||||
const auto tag_id_result { co_await db->execSqlCoro( query ) };
|
||||
|
||||
if ( tag_id_result.size() != search_tags.size() )
|
||||
co_return createInternalError( "Failed to get all search tags ids. Maybe unknown tag?" );
|
||||
|
||||
std::vector< TagID > tag_ids {};
|
||||
tag_ids.reserve( search_tags.size() );
|
||||
|
||||
for ( const auto& row : tag_id_result )
|
||||
{
|
||||
tag_ids.emplace_back( row[ "tag_id" ].as< TagID >() );
|
||||
}
|
||||
|
||||
builder.setTags( tag_ids );
|
||||
|
||||
// TODO: file domains. For now we'll assume all files
|
||||
|
||||
// TODO: Tag service key, Which tag domain to search. Defaults to all tags
|
||||
|
||||
// include_current_tags and include_pending_tags are both things that are not needed for IDHAN so we just skip this.
|
||||
|
||||
const auto file_sort_type { static_cast< HydrusSortType >(
|
||||
request->getOptionalParameter< std::uint64_t >( "file_sort_type" ).value_or( HydrusSortType::DEFAULT ) ) };
|
||||
|
||||
const auto file_sort_asc { request->getOptionalParameter< bool >( "file_sort_asc" ).value_or( true ) };
|
||||
|
||||
builder.setSortType( hyToIDHANSortType( file_sort_type ) );
|
||||
builder.setSortOrder( file_sort_asc ? SortOrder::ASC : SortOrder::DESC );
|
||||
|
||||
const auto return_file_ids { request->getOptionalParameter< bool >( "return_file_ids" ).value_or( true ) };
|
||||
const auto return_hashes { request->getOptionalParameter< bool >( "return_hashes" ).value_or( false ) };
|
||||
const auto tag_display_type {
|
||||
request->getOptionalParameter< std::string >( "tag_display_type" ).value_or( "display" )
|
||||
};
|
||||
|
||||
if ( tag_display_type == std::string_view( "storage" ) )
|
||||
{
|
||||
builder.setDisplay( HydrusDisplayType::STORED );
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.setDisplay( HydrusDisplayType::DISPLAY );
|
||||
}
|
||||
|
||||
auto end = std::chrono::system_clock::now();
|
||||
const auto diff { std::chrono::duration_cast< std::chrono::milliseconds >( end - start ).count() };
|
||||
log::info( "Setup took {}ms", diff );
|
||||
|
||||
auto query_start = std::chrono::system_clock::now();
|
||||
const auto result { co_await builder.query( db, {} ) };
|
||||
auto query_end = std::chrono::system_clock::now();
|
||||
const auto query_diff {
|
||||
std::chrono::duration_cast< std::chrono::milliseconds >( query_end - query_start ).count()
|
||||
};
|
||||
log::info( "Query took {}ms", query_diff );
|
||||
|
||||
Json::Value out {};
|
||||
|
||||
const auto json_start = std::chrono::system_clock::now();
|
||||
Json::Value file_ids {};
|
||||
Json::Value hashes {};
|
||||
Json::ArrayIndex i { 0 };
|
||||
|
||||
file_ids.resize( static_cast< Json::Value::ArrayIndex >( result.size() ) );
|
||||
hashes.resize( static_cast< Json::Value::ArrayIndex >( result.size() ) );
|
||||
|
||||
for ( const auto& row : result )
|
||||
{
|
||||
if ( return_file_ids ) file_ids[ i ] = row[ "record_id" ].as< RecordID >();
|
||||
if ( return_hashes ) hashes[ i ] = SHA256::fromPgCol( row[ "sha256" ] ).hex();
|
||||
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if ( return_file_ids ) out[ "file_ids" ] = std::move( file_ids );
|
||||
if ( return_hashes ) out[ "hashes" ] = std::move( hashes );
|
||||
|
||||
const auto json_end = std::chrono::system_clock::now();
|
||||
const auto json_diff { std::chrono::duration_cast< std::chrono::milliseconds >( json_end - json_start ).count() };
|
||||
log::info( "JSON took {}ms", json_diff );
|
||||
|
||||
co_return drogon::HttpResponse::newHttpJsonResponse( out );
|
||||
}
|
||||
|
||||
drogon::Task< drogon::HttpResponsePtr > HydrusAPI::fileHashes( [[maybe_unused]] drogon::HttpRequestPtr request )
|
||||
{
|
||||
idhan::fixme();
|
||||
@@ -353,7 +206,7 @@ drogon::Task< drogon::HttpResponsePtr > HydrusAPI::thumbnail( drogon::HttpReques
|
||||
const auto db { drogon::app().getDbClient() };
|
||||
const auto sha256 { SHA256::fromHex( hash.value() ) };
|
||||
|
||||
if ( const auto record_id_e { co_await api::helpers::findRecord( *sha256, db ) } )
|
||||
if ( const auto record_id_e { co_await idhan::helpers::findRecord( *sha256, db ) } )
|
||||
record_id = record_id_e.value();
|
||||
else
|
||||
co_return createNotFound( "No record with hash {} found", hash.value() );
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
|
||||
#include <expected>
|
||||
|
||||
#include "threading/ExpectedTask.hpp"
|
||||
#include "IDHANTypes.hpp"
|
||||
#include "api/helpers/ExpectedTask.hpp"
|
||||
#include "api/helpers/ResponseCallback.hpp"
|
||||
#include "db/dbTypes.hpp"
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
// Created by kj16609 on 7/24/25.
|
||||
//
|
||||
|
||||
#include "../records/records.hpp"
|
||||
#include "../urls/urls.hpp"
|
||||
#include "HyAPI.hpp"
|
||||
#include "api/helpers/createBadRequest.hpp"
|
||||
#include "api/helpers/records.hpp"
|
||||
#include "api/helpers/urls.hpp"
|
||||
#include "hyapi/helpers.hpp"
|
||||
|
||||
namespace idhan::hyapi
|
||||
|
||||
138
IDHANServer/src/hyapi/endpoints/searchFiles.cpp
Normal file
138
IDHANServer/src/hyapi/endpoints/searchFiles.cpp
Normal file
@@ -0,0 +1,138 @@
|
||||
//
|
||||
// Created by kj16609 on 11/14/25.
|
||||
//
|
||||
|
||||
#include "api/helpers/createBadRequest.hpp"
|
||||
#include "core/search/SearchBuilder.hpp"
|
||||
#include "crypto/SHA256.hpp"
|
||||
#include "hyapi/HyAPI.hpp"
|
||||
#include "logging/ScopedTimer.hpp"
|
||||
|
||||
namespace idhan::hyapi
|
||||
{
|
||||
|
||||
drogon::Task< drogon::HttpResponsePtr > HydrusAPI::searchFiles( drogon::HttpRequestPtr request )
|
||||
{
|
||||
logging::ScopedTimer timer { "Search files", std::chrono::seconds( 2 ) };
|
||||
const auto start = std::chrono::system_clock::now();
|
||||
const auto tags_o { request->getOptionalParameter< std::string >( "tags" ) };
|
||||
constexpr auto empty_tags { "[]" };
|
||||
const auto tags_parameter_str { tags_o.value_or( empty_tags ) };
|
||||
if ( tags_parameter_str == empty_tags )
|
||||
{
|
||||
Json::Value value;
|
||||
value[ "file_ids" ] = Json::Value( Json::arrayValue );
|
||||
|
||||
co_return drogon::HttpResponse::newHttpJsonResponse( value );
|
||||
}
|
||||
|
||||
auto db { drogon::app().getDbClient() };
|
||||
|
||||
// Build the search
|
||||
SearchBuilder builder {};
|
||||
|
||||
Json::Value tags_json {};
|
||||
Json::Reader reader;
|
||||
if ( !reader.parse( tags_parameter_str, tags_json ) )
|
||||
{
|
||||
// Try to decode the text again and re-parse it
|
||||
const auto decoded_tags { drogon::utils::urlDecode( tags_parameter_str ) };
|
||||
if ( reader.parse( decoded_tags, tags_json ) )
|
||||
{
|
||||
log::warn( "Tags JSON had to be URL-decoded a second time. Call the requester an idiot" );
|
||||
}
|
||||
else
|
||||
{
|
||||
co_return createBadRequest( "Invalid tags json: Was {}", tags_parameter_str );
|
||||
}
|
||||
}
|
||||
|
||||
std::vector< std::string > search_tags {};
|
||||
search_tags.reserve( tags_json.size() );
|
||||
std::vector< std::string > system_tags {};
|
||||
|
||||
for ( const auto& tag : tags_json )
|
||||
{
|
||||
const auto tag_text { tag.asString() };
|
||||
if ( tag_text.starts_with( "system:" ) )
|
||||
{
|
||||
system_tags.emplace_back( tag_text );
|
||||
continue;
|
||||
}
|
||||
|
||||
search_tags.emplace_back( tag_text );
|
||||
}
|
||||
|
||||
const auto search_result { co_await builder.setTags( search_tags ) };
|
||||
builder.setSystemTags( system_tags );
|
||||
|
||||
// TODO: file domains. For now we'll assume all files
|
||||
|
||||
// TODO: Tag service key, Which tag domain to search. Defaults to all tags
|
||||
|
||||
// include_current_tags and include_pending_tags are both things that are not needed for IDHAN so we just skip this.
|
||||
|
||||
const auto file_sort_type { static_cast< HydrusSortType >(
|
||||
request->getOptionalParameter< std::uint64_t >( "file_sort_type" ).value_or( HydrusSortType::DEFAULT ) ) };
|
||||
|
||||
const auto file_sort_asc { request->getOptionalParameter< bool >( "file_sort_asc" ).value_or( true ) };
|
||||
|
||||
builder.setSortType( hyToIDHANSortType( file_sort_type ) );
|
||||
builder.setSortOrder( file_sort_asc ? SortOrder::ASC : SortOrder::DESC );
|
||||
|
||||
const auto return_file_ids { request->getOptionalParameter< bool >( "return_file_ids" ).value_or( true ) };
|
||||
const auto return_hashes { request->getOptionalParameter< bool >( "return_hashes" ).value_or( false ) };
|
||||
const auto tag_display_type {
|
||||
request->getOptionalParameter< std::string >( "tag_display_type" ).value_or( "display" )
|
||||
};
|
||||
|
||||
if ( tag_display_type == std::string_view( "storage" ) )
|
||||
{
|
||||
builder.setDisplay( HydrusDisplayType::STORED );
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.setDisplay( HydrusDisplayType::DISPLAY );
|
||||
}
|
||||
|
||||
auto end = std::chrono::system_clock::now();
|
||||
const auto diff { std::chrono::duration_cast< std::chrono::milliseconds >( end - start ).count() };
|
||||
log::info( "Setup took {}ms", diff );
|
||||
|
||||
auto query_start = std::chrono::system_clock::now();
|
||||
const auto result { co_await builder.query( db, {} ) };
|
||||
auto query_end = std::chrono::system_clock::now();
|
||||
const auto query_diff {
|
||||
std::chrono::duration_cast< std::chrono::milliseconds >( query_end - query_start ).count()
|
||||
};
|
||||
log::info( "Query took {}ms", query_diff );
|
||||
|
||||
Json::Value out {};
|
||||
|
||||
const auto json_start = std::chrono::system_clock::now();
|
||||
Json::Value file_ids {};
|
||||
Json::Value hashes {};
|
||||
Json::ArrayIndex i { 0 };
|
||||
|
||||
file_ids.resize( static_cast< Json::Value::ArrayIndex >( result.size() ) );
|
||||
hashes.resize( static_cast< Json::Value::ArrayIndex >( result.size() ) );
|
||||
|
||||
for ( const auto& row : result )
|
||||
{
|
||||
if ( return_file_ids ) file_ids[ i ] = row[ "record_id" ].as< RecordID >();
|
||||
if ( return_hashes ) hashes[ i ] = SHA256::fromPgCol( row[ "sha256" ] ).hex();
|
||||
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if ( return_file_ids ) out[ "file_ids" ] = std::move( file_ids );
|
||||
if ( return_hashes ) out[ "hashes" ] = std::move( hashes );
|
||||
|
||||
const auto json_end = std::chrono::system_clock::now();
|
||||
const auto json_diff { std::chrono::duration_cast< std::chrono::milliseconds >( json_end - json_start ).count() };
|
||||
log::info( "JSON took {}ms", json_diff );
|
||||
|
||||
co_return drogon::HttpResponse::newHttpJsonResponse( out );
|
||||
}
|
||||
|
||||
} // namespace idhan::hyapi
|
||||
@@ -2,11 +2,11 @@
|
||||
// Created by kj16609 on 7/23/25.
|
||||
//
|
||||
|
||||
#include "../records/records.hpp"
|
||||
#include "HyAPI.hpp"
|
||||
#include "IDHANTypes.hpp"
|
||||
#include "api/TagAPI.hpp"
|
||||
#include "api/helpers/createBadRequest.hpp"
|
||||
#include "api/helpers/records.hpp"
|
||||
#include "api/record/urls/urls.hpp"
|
||||
#include "constants/hydrus_version.hpp"
|
||||
#include "core/search/SearchBuilder.hpp"
|
||||
@@ -15,7 +15,7 @@
|
||||
#include "drogon/utils/coroutine.h"
|
||||
#include "fgl/defines.hpp"
|
||||
#include "logging/ScopedTimer.hpp"
|
||||
#include "metadata/parseMetadata.hpp"
|
||||
#include "metadata/metadata.hpp"
|
||||
|
||||
namespace idhan::hyapi
|
||||
{
|
||||
@@ -50,65 +50,28 @@ drogon::Task< std::expected< Json::Value, drogon::HttpResponsePtr > > getMetadat
|
||||
const RecordID record_id,
|
||||
Json::Value data )
|
||||
{
|
||||
auto metadata = co_await db->execSqlCoro( "SELECT simple_mime_type FROM metadata WHERE record_id = $1", record_id );
|
||||
auto metadata = co_await db->execSqlCoro(
|
||||
"SELECT simple_mime_type, mime.name as mime_name FROM metadata JOIN file_info USING (record_id) JOIN MIME USING (mime_id) WHERE record_id = $1",
|
||||
record_id );
|
||||
|
||||
if ( metadata.empty() )
|
||||
{
|
||||
log::warn( "Metadata missing for record {} Attempting to acquire metadata", record_id );
|
||||
const auto parse_result { co_await api::tryParseRecordMetadata( record_id, db ) };
|
||||
const auto parse_result { co_await metadata::tryParseRecordMetadata( record_id, db ) };
|
||||
if ( !parse_result ) co_return std::unexpected( parse_result.error() );
|
||||
|
||||
metadata = co_await db->execSqlCoro( "SELECT simple_mime_type FROM metadata WHERE record_id = $1", record_id );
|
||||
metadata = co_await db->execSqlCoro(
|
||||
"SELECT simple_mime_type, mime.name as mime_name FROM metadata JOIN file_info USING (record_id) JOIN MIME USING (mime_id) WHERE record_id = $1",
|
||||
record_id );
|
||||
|
||||
if ( metadata.empty() )
|
||||
co_return std::unexpected( createInternalError( "Failed to get mime type for record {}", record_id ) );
|
||||
}
|
||||
|
||||
const SimpleMimeType simple_mime_type { metadata[ 0 ][ "simple_mime_type" ].as< std::uint16_t >() };
|
||||
const auto mime_name { metadata[ 0 ][ "mime_name" ].as< std::string >() };
|
||||
data[ "filetype_enum" ] = hydrus::hy_constants::mimeToHyType( mime_name );
|
||||
|
||||
data[ "filetype_enum" ] =
|
||||
static_cast< Json::Value::Int >( hydrus::hy_constants::simpleToHyType( simple_mime_type ) );
|
||||
|
||||
switch ( simple_mime_type )
|
||||
{
|
||||
case SimpleMimeType::VIDEO:
|
||||
FGL_UNIMPLEMENTED();
|
||||
case SimpleMimeType::NONE:
|
||||
// NOOP
|
||||
break;
|
||||
case SimpleMimeType::IMAGE:
|
||||
{
|
||||
const auto image_metadata { co_await db->execSqlCoro(
|
||||
"SELECT width, height FROM image_metadata WHERE record_id = $1", record_id ) };
|
||||
|
||||
if ( !image_metadata.empty() )
|
||||
{
|
||||
data[ "width" ] = image_metadata[ 0 ][ "width" ].as< std::uint32_t >();
|
||||
data[ "height" ] = image_metadata[ 0 ][ "height" ].as< std::uint32_t >();
|
||||
}
|
||||
else
|
||||
{
|
||||
data[ "width" ] = 0;
|
||||
data[ "height" ] = 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SimpleMimeType::ANIMATION:
|
||||
FGL_UNIMPLEMENTED();
|
||||
break;
|
||||
case SimpleMimeType::AUDIO:
|
||||
FGL_UNIMPLEMENTED();
|
||||
break;
|
||||
case SimpleMimeType::ARCHIVE:
|
||||
FGL_UNIMPLEMENTED();
|
||||
break;
|
||||
case SimpleMimeType::IMAGE_PROJECT:
|
||||
FGL_UNIMPLEMENTED();
|
||||
break;
|
||||
default:
|
||||
co_return std::unexpected(
|
||||
createInternalError( "Given file with unhandlable simple mime type for record {}", record_id ) );
|
||||
}
|
||||
co_await metadata::addFileSpecificInfo( data, record_id, db );
|
||||
|
||||
co_return data;
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
#include "helpers.hpp"
|
||||
|
||||
#include "../records/records.hpp"
|
||||
#include "IDHANTypes.hpp"
|
||||
#include "api/helpers/createBadRequest.hpp"
|
||||
#include "api/helpers/records.hpp"
|
||||
#include "crypto/SHA256.hpp"
|
||||
#include "drogon/HttpAppFramework.h"
|
||||
#include "drogon/HttpResponse.h"
|
||||
@@ -28,7 +28,7 @@ drogon::Task< std::expected< std::vector< RecordID >, drogon::HttpResponsePtr >
|
||||
const auto hash { json[ i ].asString() };
|
||||
const auto sha256 { SHA256::fromHex( hash ) };
|
||||
if ( !sha256 ) co_return std::unexpected( sha256.error() );
|
||||
const auto record_id { co_await api::helpers::findRecord( *sha256, db ) };
|
||||
const auto record_id { co_await idhan::helpers::findRecord( *sha256, db ) };
|
||||
if ( !record_id ) co_return std::unexpected( createBadRequest( "Invalid SHA256" ) );
|
||||
records.emplace_back( record_id.value() );
|
||||
}
|
||||
@@ -51,7 +51,7 @@ drogon::Task< std::expected< std::vector< RecordID >, drogon::HttpResponsePtr >
|
||||
const auto hash { json[ "hash" ].asString() };
|
||||
const auto sha256 { SHA256::fromHex( hash ) };
|
||||
if ( !sha256 ) co_return std::unexpected( sha256.error() );
|
||||
const auto record_id { co_await api::helpers::findRecord( *sha256, db ) };
|
||||
const auto record_id { co_await idhan::helpers::findRecord( *sha256, db ) };
|
||||
if ( !record_id ) co_return std::unexpected( createBadRequest( "Invalid SHA256" ) );
|
||||
records.emplace_back( record_id.value() );
|
||||
co_return records;
|
||||
@@ -112,7 +112,7 @@ drogon::Task< std::expected< std::vector< RecordID >, drogon::HttpResponsePtr >
|
||||
{
|
||||
const auto sha256 { SHA256::fromHex( opt.value() ) };
|
||||
if ( !sha256 ) co_return std::unexpected( sha256.error() );
|
||||
const auto record_id { co_await api::helpers::findRecord( *sha256, db ) };
|
||||
const auto record_id { co_await idhan::helpers::findRecord( *sha256, db ) };
|
||||
if ( !record_id ) co_return std::unexpected( createBadRequest( "Invalid SHA256" ) );
|
||||
std::vector< RecordID > records { record_id.value() };
|
||||
co_return records;
|
||||
|
||||
85
IDHANServer/src/hyapi/middleware/HyAPIHashConversion.cpp
Normal file
85
IDHANServer/src/hyapi/middleware/HyAPIHashConversion.cpp
Normal file
@@ -0,0 +1,85 @@
|
||||
//
|
||||
// Created by kj16609 on 11/13/25.
|
||||
//
|
||||
|
||||
#include "HyAPIHashConversion.hpp"
|
||||
|
||||
#include <drogon/HttpRequest.h>
|
||||
#include <drogon/HttpResponse.h>
|
||||
#include <drogon/drogon.h>
|
||||
#include <json/json.h>
|
||||
|
||||
#include "IDHANTypes.hpp"
|
||||
#include "api/helpers/createBadRequest.hpp"
|
||||
#include "crypto/SHA256.hpp"
|
||||
#include "db/drogonArrayBind.hpp"
|
||||
#include "records/records.hpp"
|
||||
|
||||
namespace idhan::hyapi
|
||||
{
|
||||
|
||||
drogon::Task< drogon::HttpResponsePtr > HyAPIHashConversion::invoke(
|
||||
const drogon::HttpRequestPtr& request,
|
||||
drogon::MiddlewareNextAwaiter&& next )
|
||||
{
|
||||
if ( const auto opt_param = request->getOptionalParameter< std::string >( "hashes" ); opt_param )
|
||||
{
|
||||
Json::Value hashes {};
|
||||
Json::Reader reader {};
|
||||
if ( !reader.parse( *opt_param, hashes ) || !hashes.isArray() )
|
||||
{
|
||||
co_return createBadRequest( "Invalid 'hashes' parameter format. Expected JSON array." );
|
||||
}
|
||||
|
||||
std::vector< SHA256 > hash_values {};
|
||||
hash_values.reserve( hashes.size() );
|
||||
|
||||
for ( const auto& hash : hashes )
|
||||
{
|
||||
if ( !hash.isString() )
|
||||
{
|
||||
co_return createBadRequest( "Invalid hash format. Expected string." );
|
||||
}
|
||||
|
||||
const auto sha256 { SHA256::fromHex( hash.as< std::string >() ) };
|
||||
if ( !sha256 )
|
||||
{
|
||||
co_return createBadRequest( "Invalid hash format. Invalid hash string." );
|
||||
}
|
||||
|
||||
hash_values.emplace_back( *sha256 );
|
||||
}
|
||||
|
||||
auto db = drogon::app().getDbClient();
|
||||
try
|
||||
{
|
||||
std::vector< RecordID > records {};
|
||||
|
||||
for ( const auto& hash : hash_values )
|
||||
{
|
||||
const auto record_id { co_await helpers::createRecord( hash, db ) };
|
||||
if ( !record_id ) co_return createBadRequest( "Failed to create record for hash {}", hash.hex() );
|
||||
|
||||
records.emplace_back( *record_id );
|
||||
}
|
||||
|
||||
Json::Value hash_ids( Json::arrayValue );
|
||||
for ( const auto& record : records )
|
||||
{
|
||||
hash_ids.append( static_cast< Json::UInt64 >( record ) );
|
||||
}
|
||||
|
||||
request->setParameter( "hash_ids", hash_ids.toStyledString() );
|
||||
|
||||
co_return co_await next;
|
||||
}
|
||||
catch ( const std::exception& e )
|
||||
{
|
||||
co_return createInternalError( e.what() );
|
||||
}
|
||||
}
|
||||
|
||||
co_return co_await next;
|
||||
}
|
||||
|
||||
} // namespace idhan::hyapi
|
||||
28
IDHANServer/src/hyapi/middleware/HyAPIHashConversion.hpp
Normal file
28
IDHANServer/src/hyapi/middleware/HyAPIHashConversion.hpp
Normal file
@@ -0,0 +1,28 @@
|
||||
//
|
||||
// Created by kj16609 on 11/13/25.
|
||||
//
|
||||
#pragma once
|
||||
|
||||
#include <drogon/HttpFilter.h>
|
||||
#include <drogon/HttpTypes.h>
|
||||
|
||||
namespace idhan::hyapi
|
||||
{
|
||||
|
||||
/**
|
||||
* @brief Middleware that converts 'hashes' parameter to 'hash_ids' parameter
|
||||
*
|
||||
* This filter intercepts requests containing a 'hashes' parameter (JSON array of SHA256 hex strings)
|
||||
* and converts them to 'hash_ids' (JSON array of record IDs) by looking them up in the database.
|
||||
* The mapping is done using the records table (sha256, record_id).
|
||||
*/
|
||||
class HyAPIHashConversion : public drogon::HttpCoroMiddleware< HyAPIHashConversion >
|
||||
{
|
||||
public:
|
||||
|
||||
drogon::Task< drogon::HttpResponsePtr > invoke(
|
||||
const drogon::HttpRequestPtr& request,
|
||||
drogon::MiddlewareNextAwaiter&& next ) override;
|
||||
};
|
||||
|
||||
} // namespace idhan::hyapi
|
||||
@@ -1,44 +0,0 @@
|
||||
//
|
||||
// Created by kj16609 on 5/20/25.
|
||||
//
|
||||
|
||||
#include "FileMappedData.hpp"
|
||||
|
||||
#include <filesystem>
|
||||
|
||||
namespace idhan
|
||||
{
|
||||
|
||||
FileMappedData::FileMappedData( const std::filesystem::path& path_i ) :
|
||||
m_path( path_i ),
|
||||
m_length( std::filesystem::file_size( path_i ) )
|
||||
{}
|
||||
|
||||
std::string FileMappedData::extension() const
|
||||
{
|
||||
return m_path.extension().string();
|
||||
}
|
||||
|
||||
std::string FileMappedData::name() const
|
||||
{
|
||||
return m_path.stem().string();
|
||||
}
|
||||
|
||||
std::size_t FileMappedData::length() const
|
||||
{
|
||||
return m_length;
|
||||
}
|
||||
|
||||
std::filesystem::path FileMappedData::path() const
|
||||
{
|
||||
return m_path;
|
||||
}
|
||||
|
||||
std::string FileMappedData::strpath() const
|
||||
{
|
||||
return m_path.string();
|
||||
}
|
||||
|
||||
FileMappedData::~FileMappedData() = default;
|
||||
|
||||
} // namespace idhan
|
||||
@@ -1,37 +0,0 @@
|
||||
//
|
||||
// Created by kj16609 on 5/20/25.
|
||||
//
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
|
||||
#include "fgl/defines.hpp"
|
||||
|
||||
namespace idhan
|
||||
{
|
||||
|
||||
class FileMappedData
|
||||
{
|
||||
std::filesystem::path m_path;
|
||||
std::size_t m_length;
|
||||
|
||||
public:
|
||||
|
||||
FGL_DELETE_ALL_RO5( FileMappedData );
|
||||
|
||||
explicit FileMappedData( const std::filesystem::path& path_i );
|
||||
|
||||
std::string extension() const;
|
||||
|
||||
std::string name() const;
|
||||
|
||||
std::size_t length() const;
|
||||
|
||||
std::filesystem::path path() const;
|
||||
|
||||
std::string strpath() const;
|
||||
|
||||
~FileMappedData();
|
||||
};
|
||||
|
||||
} // namespace idhan
|
||||
117
IDHANServer/src/metadata/addFileSpecificInfo.cpp
Normal file
117
IDHANServer/src/metadata/addFileSpecificInfo.cpp
Normal file
@@ -0,0 +1,117 @@
|
||||
//
|
||||
// Created by kj16609 on 11/13/25.
|
||||
//
|
||||
|
||||
#include "api/helpers/createBadRequest.hpp"
|
||||
#include "metadata.hpp"
|
||||
|
||||
namespace idhan::metadata
|
||||
{
|
||||
|
||||
ExpectedTask< void > addImageInfo( Json::Value& root, const RecordID record_id, DbClientPtr db )
|
||||
{
|
||||
const auto metadata { co_await db->execSqlCoro( "SELECT * FROM image_metadata WHERE record_id = $1", record_id ) };
|
||||
|
||||
if ( metadata.empty() )
|
||||
co_return std::unexpected( createInternalError( "Could not find image metadata for record {}", record_id ) );
|
||||
|
||||
root[ "width" ] = metadata[ 0 ][ "width" ].as< std::uint32_t >();
|
||||
root[ "height" ] = metadata[ 0 ][ "height" ].as< std::uint32_t >();
|
||||
root[ "channels" ] = metadata[ 0 ][ "channels" ].as< std::uint32_t >();
|
||||
|
||||
co_return std::expected< void, drogon::HttpResponsePtr >();
|
||||
}
|
||||
|
||||
ExpectedTask< void > addVideoInfo( Json::Value& root, const RecordID record_id, DbClientPtr db )
|
||||
{
|
||||
const auto metadata { co_await db->execSqlCoro( "SELECT * FROM video_metadata WHERE record_id = $1", record_id ) };
|
||||
|
||||
if ( metadata.empty() )
|
||||
co_return std::unexpected( createInternalError( "Could not find video metadata for record {}", record_id ) );
|
||||
|
||||
root[ "width" ] = metadata[ 0 ][ "width" ].as< std::uint32_t >();
|
||||
root[ "height" ] = metadata[ 0 ][ "height" ].as< std::uint32_t >();
|
||||
|
||||
root[ "duration" ] = metadata[ 0 ][ "duration" ].as< double >();
|
||||
|
||||
root[ "bitrate" ] = metadata[ 0 ][ "bitrate" ].as< std::uint32_t >();
|
||||
|
||||
root[ "has_audio" ] = metadata[ 0 ][ "has_audio" ].as< bool >();
|
||||
|
||||
co_return {};
|
||||
}
|
||||
|
||||
ExpectedTask< void > addImageProjectInfo( Json::Value& root, const RecordID record_id, DbClientPtr db )
|
||||
{
|
||||
const auto metadata {
|
||||
co_await db->execSqlCoro( "SELECT * FROM image_project_metadata WHERE record_id = $1", record_id )
|
||||
};
|
||||
|
||||
if ( metadata.empty() )
|
||||
co_return std::unexpected(
|
||||
createInternalError( "Could not find image project metadata for record {}", record_id ) );
|
||||
|
||||
root[ "width" ] = metadata[ 0 ][ "width" ].as< std::uint32_t >();
|
||||
root[ "height" ] = metadata[ 0 ][ "height" ].as< std::uint32_t >();
|
||||
root[ "channels" ] = metadata[ 0 ][ "channels" ].as< SmallInt >();
|
||||
root[ "layers" ] = metadata[ 0 ][ "layers" ].as< SmallInt >();
|
||||
|
||||
co_return {};
|
||||
}
|
||||
|
||||
ExpectedTask< void > addFileSpecificInfo( Json::Value& root, const RecordID record_id, DbClientPtr db )
|
||||
{
|
||||
auto simple_mime_result {
|
||||
co_await db->execSqlCoro( "SELECT simple_mime_type FROM metadata WHERE record_id = $1", record_id )
|
||||
};
|
||||
|
||||
if ( simple_mime_result.empty() ) // Could not find any mime info for this record, Try parsing for it.
|
||||
{
|
||||
const auto parsed_metadata { co_await tryParseRecordMetadata( record_id, db ) };
|
||||
|
||||
if ( !parsed_metadata ) co_return std::unexpected( parsed_metadata.error() );
|
||||
|
||||
simple_mime_result =
|
||||
co_await db->execSqlCoro( "SELECT simple_mime_type FROM metadata WHERE record_id = $1", record_id );
|
||||
}
|
||||
|
||||
if ( simple_mime_result.empty() )
|
||||
co_return std::unexpected( createInternalError( "Failed to get simple mime type for record {}", record_id ) );
|
||||
|
||||
const SimpleMimeType simple_mime_type { simple_mime_result[ 0 ][ "simple_mime_type" ].as< std::uint16_t >() };
|
||||
|
||||
switch ( simple_mime_type )
|
||||
{
|
||||
case SimpleMimeType::IMAGE:
|
||||
{
|
||||
const auto result { co_await addImageInfo( root, record_id, db ) };
|
||||
if ( !result ) co_return std::unexpected( result.error() );
|
||||
break;
|
||||
}
|
||||
case SimpleMimeType::VIDEO:
|
||||
{
|
||||
const auto result { co_await addVideoInfo( root, record_id, db ) };
|
||||
if ( !result ) co_return std::unexpected( result.error() );
|
||||
break;
|
||||
}
|
||||
case SimpleMimeType::IMAGE_PROJECT:
|
||||
{
|
||||
const auto result { co_await addImageProjectInfo( root, record_id, db ) };
|
||||
if ( !result ) co_return std::unexpected( result.error() );
|
||||
break;
|
||||
}
|
||||
case SimpleMimeType::ANIMATION:
|
||||
[[fallthrough]];
|
||||
case SimpleMimeType::AUDIO:
|
||||
[[fallthrough]];
|
||||
case SimpleMimeType::NONE:
|
||||
[[fallthrough]];
|
||||
default:
|
||||
co_return std::unexpected( createInternalError(
|
||||
"No handler for adding metadata with given simple mime {}", static_cast< int >( simple_mime_type ) ) );
|
||||
}
|
||||
|
||||
co_return std::expected< void, drogon::HttpResponsePtr >();
|
||||
}
|
||||
|
||||
} // namespace idhan::metadata
|
||||
21
IDHANServer/src/metadata/findBestParser.cpp
Normal file
21
IDHANServer/src/metadata/findBestParser.cpp
Normal file
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// Created by kj16609 on 11/13/25.
|
||||
//
|
||||
|
||||
#include "drogon/utils/coroutine.h"
|
||||
#include "modules/ModuleLoader.hpp"
|
||||
|
||||
namespace idhan::metadata
|
||||
{
|
||||
|
||||
drogon::Task< std::shared_ptr< MetadataModuleI > > findBestParser( const std::string mime_name )
|
||||
{
|
||||
auto parsers { modules::ModuleLoader::instance().getParserFor( mime_name ) };
|
||||
|
||||
if ( parsers.empty() ) co_return {};
|
||||
|
||||
// return the first parser
|
||||
co_return parsers[ 0 ];
|
||||
}
|
||||
|
||||
} // namespace idhan::metadata
|
||||
16
IDHANServer/src/metadata/getMetadata.cpp
Normal file
16
IDHANServer/src/metadata/getMetadata.cpp
Normal file
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// Created by kj16609 on 11/13/25.
|
||||
//
|
||||
|
||||
#include "fgl/defines.hpp"
|
||||
#include "metadata.hpp"
|
||||
|
||||
namespace idhan::metadata
|
||||
{
|
||||
|
||||
drogon::Task< MetadataInfo > getMetadata( [[maybe_unused]] const RecordID record_id, [[maybe_unused]] DbClientPtr db )
|
||||
{
|
||||
FGL_UNIMPLEMENTED();
|
||||
}
|
||||
|
||||
} // namespace idhan::metadata
|
||||
@@ -1,13 +1,14 @@
|
||||
//
|
||||
// Created by kj16609 on 6/12/25.
|
||||
// Created by kj16609 on 11/13/25.
|
||||
//
|
||||
#pragma once
|
||||
|
||||
#include <drogon/drogon.h>
|
||||
#include <expected>
|
||||
|
||||
#include "IDHANTypes.hpp"
|
||||
#include "api/helpers/ExpectedTask.hpp"
|
||||
#include "db/dbTypes.hpp"
|
||||
#include "drogon/HttpResponse.h"
|
||||
#include "drogon/utils/coroutine.h"
|
||||
#include "threading/ExpectedTask.hpp"
|
||||
|
||||
namespace idhan
|
||||
{
|
||||
@@ -16,9 +17,15 @@ class FileMappedData;
|
||||
struct MetadataInfo;
|
||||
} // namespace idhan
|
||||
|
||||
namespace idhan::api
|
||||
namespace idhan::metadata
|
||||
{
|
||||
|
||||
// DB
|
||||
|
||||
ExpectedTask< void > addFileSpecificInfo( Json::Value& root, RecordID record_id, DbClientPtr db );
|
||||
|
||||
// Parsing
|
||||
|
||||
drogon::Task< std::shared_ptr< MetadataModuleI > > findBestParser( std::string mime_name );
|
||||
|
||||
//! Triggers the metadata parsing for a record and updates it
|
||||
@@ -32,4 +39,4 @@ ExpectedTask< void > updateRecordMetadata( RecordID record_id, DbClientPtr db, M
|
||||
|
||||
drogon::Task< MetadataInfo > getMetadata( RecordID record_id, DbClientPtr db );
|
||||
|
||||
} // namespace idhan::api
|
||||
} // namespace idhan::metadata
|
||||
@@ -2,118 +2,23 @@
|
||||
// Created by kj16609 on 6/12/25.
|
||||
//
|
||||
|
||||
#include "parseMetadata.hpp"
|
||||
|
||||
#include <drogon/drogon.h>
|
||||
#include <json/json.h>
|
||||
|
||||
#include "api/helpers/ExpectedTask.hpp"
|
||||
#include "../filesystem/clusters/ClusterManager.hpp"
|
||||
#include "../filesystem/io/IOUring.hpp"
|
||||
#include "api/helpers/createBadRequest.hpp"
|
||||
#include "api/helpers/helpers.hpp"
|
||||
#include "filesystem/IOUring.hpp"
|
||||
#include "filesystem/filesystem.hpp"
|
||||
#include "metadata.hpp"
|
||||
#include "modules/ModuleLoader.hpp"
|
||||
#include "threading/ExpectedTask.hpp"
|
||||
|
||||
namespace idhan::api
|
||||
namespace idhan::metadata
|
||||
{
|
||||
|
||||
ExpectedTask< void > updateRecordMetadata( const RecordID record_id, DbClientPtr db, MetadataInfo metadata )
|
||||
{
|
||||
const auto simple_type { metadata.m_simple_type };
|
||||
|
||||
co_await db->execSqlCoro(
|
||||
"INSERT INTO metadata (record_id, simple_mime_type) VALUES ($1, $2) "
|
||||
"ON CONFLICT (record_id) DO UPDATE SET simple_mime_type = $2",
|
||||
record_id,
|
||||
simple_type );
|
||||
|
||||
Json::Value json {};
|
||||
Json::Reader reader {};
|
||||
if ( !metadata.m_extra.empty() )
|
||||
{
|
||||
if ( !reader.parse( metadata.m_extra, json ) )
|
||||
co_return std::unexpected( createBadRequest( "Failed to parse metadata \"{}\"", metadata.m_extra ) );
|
||||
|
||||
co_await db->execSqlCoro(
|
||||
"UPDATE metadata SET json = $2 WHERE record_id = $1", record_id, json.toStyledString() );
|
||||
}
|
||||
|
||||
switch ( simple_type )
|
||||
{
|
||||
case SimpleMimeType::IMAGE:
|
||||
{
|
||||
const auto& image_metadata { std::get< MetadataInfoImage >( metadata.m_metadata ) };
|
||||
co_await db->execSqlCoro(
|
||||
"INSERT INTO image_metadata (record_id, width, height, channels) VALUES ($1, $2, $3, $4) "
|
||||
"ON CONFLICT (record_id) DO UPDATE SET width = $2, height = $3, channels = $4",
|
||||
record_id,
|
||||
image_metadata.width,
|
||||
image_metadata.height,
|
||||
static_cast< std::uint16_t >( image_metadata.channels ) );
|
||||
break;
|
||||
}
|
||||
case SimpleMimeType::VIDEO:
|
||||
FGL_UNIMPLEMENTED();
|
||||
break;
|
||||
case SimpleMimeType::ANIMATION:
|
||||
FGL_UNIMPLEMENTED();
|
||||
break;
|
||||
case SimpleMimeType::AUDIO:
|
||||
FGL_UNIMPLEMENTED();
|
||||
break;
|
||||
case SimpleMimeType::NONE:
|
||||
break;
|
||||
default:;
|
||||
}
|
||||
|
||||
co_return {};
|
||||
}
|
||||
|
||||
drogon::Task< MetadataInfo > getMetadata( [[maybe_unused]] const RecordID record_id, [[maybe_unused]] DbClientPtr db )
|
||||
{
|
||||
FGL_UNIMPLEMENTED();
|
||||
}
|
||||
|
||||
drogon::Task< std::shared_ptr< MetadataModuleI > > findBestParser( const std::string mime_name )
|
||||
{
|
||||
auto parsers { modules::ModuleLoader::instance().getParserFor( mime_name ) };
|
||||
|
||||
if ( parsers.empty() ) co_return {};
|
||||
|
||||
// return the first parser
|
||||
co_return parsers[ 0 ];
|
||||
}
|
||||
|
||||
ExpectedTask< FileIOUring > getIOForRecord( const RecordID record_id, DbClientPtr db )
|
||||
{
|
||||
const auto path { co_await helpers::getRecordPath( record_id, db ) };
|
||||
return_unexpected_error( path );
|
||||
|
||||
if ( !std::filesystem::exists( *path ) )
|
||||
{
|
||||
co_return std::unexpected(
|
||||
createInternalError( "Record {} does not exist at the expected path {}.", record_id, path->string() ) );
|
||||
}
|
||||
|
||||
FileIOUring uring { *path };
|
||||
co_return uring;
|
||||
}
|
||||
|
||||
ExpectedTask< void > tryParseRecordMetadata( const RecordID record_id, DbClientPtr db )
|
||||
{
|
||||
const auto metadata { co_await parseMetadata( record_id, db ) };
|
||||
return_unexpected_error( metadata );
|
||||
|
||||
co_await updateRecordMetadata( record_id, db, metadata.value() );
|
||||
|
||||
co_return {};
|
||||
}
|
||||
|
||||
ExpectedTask< MetadataInfo > parseMetadata( const RecordID record_id, DbClientPtr db )
|
||||
{
|
||||
auto io { co_await getIOForRecord( record_id, db ) };
|
||||
return_unexpected_error( io );
|
||||
|
||||
const auto [ data, length ] = io->mmap();
|
||||
log::debug( "Processing metadata for {}", record_id );
|
||||
|
||||
const auto record_mime {
|
||||
co_await db->execSqlCoro( "SELECT mime_id FROM file_info WHERE record_id = $1", record_id )
|
||||
@@ -123,7 +28,18 @@ ExpectedTask< MetadataInfo > parseMetadata( const RecordID record_id, DbClientPt
|
||||
co_return std::unexpected( createBadRequest(
|
||||
"Record {} does not exist or does not have any file info associated with it", record_id ) );
|
||||
|
||||
if ( record_mime[ 0 ][ "mime_id" ].isNull() ) co_return MetadataInfo {};
|
||||
if ( record_mime[ 0 ][ "mime_id" ].isNull() )
|
||||
{
|
||||
log::warn(
|
||||
"When trying to parse file for record {} for metadata, there was no mime associated with it", record_id );
|
||||
co_return std::unexpected( createBadRequest(
|
||||
"Record {} does not have any mime associated with it, Cannot parse metadata", record_id ) );
|
||||
}
|
||||
|
||||
auto io { co_await filesystem::getIOForRecord( record_id, db ) };
|
||||
return_unexpected_error( io );
|
||||
|
||||
const auto [ data, length ] = io->mmapReadOnly();
|
||||
|
||||
const auto mime_id { record_mime[ 0 ][ "mime_id" ].as< MimeID >() };
|
||||
|
||||
@@ -151,4 +67,4 @@ ExpectedTask< MetadataInfo > parseMetadata( const RecordID record_id, DbClientPt
|
||||
co_return metadata.value();
|
||||
}
|
||||
|
||||
} // namespace idhan::api
|
||||
} // namespace idhan::metadata
|
||||
|
||||
21
IDHANServer/src/metadata/tryParseRecordMetadata.cpp
Normal file
21
IDHANServer/src/metadata/tryParseRecordMetadata.cpp
Normal file
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// Created by kj16609 on 11/13/25.
|
||||
//
|
||||
|
||||
#include "MetadataModule.hpp"
|
||||
#include "metadata.hpp"
|
||||
|
||||
namespace idhan::metadata
|
||||
{
|
||||
|
||||
ExpectedTask< void > tryParseRecordMetadata( const RecordID record_id, DbClientPtr db )
|
||||
{
|
||||
const auto metadata { co_await parseMetadata( record_id, db ) };
|
||||
return_unexpected_error( metadata );
|
||||
|
||||
co_await updateRecordMetadata( record_id, db, metadata.value() );
|
||||
|
||||
co_return {};
|
||||
}
|
||||
|
||||
} // namespace idhan::metadata
|
||||
100
IDHANServer/src/metadata/updateRecordMetadata.cpp
Normal file
100
IDHANServer/src/metadata/updateRecordMetadata.cpp
Normal file
@@ -0,0 +1,100 @@
|
||||
//
|
||||
// Created by kj16609 on 11/13/25.
|
||||
//
|
||||
#include <drogon/drogon.h>
|
||||
#include <json/json.h>
|
||||
|
||||
#include "../filesystem/clusters/ClusterManager.hpp"
|
||||
#include "../filesystem/io/IOUring.hpp"
|
||||
#include "api/helpers/createBadRequest.hpp"
|
||||
#include "api/helpers/helpers.hpp"
|
||||
#include "metadata.hpp"
|
||||
#include "modules/ModuleLoader.hpp"
|
||||
#include "threading/ExpectedTask.hpp"
|
||||
|
||||
namespace idhan::metadata
|
||||
{
|
||||
|
||||
ExpectedTask< void > updateRecordMetadata( const RecordID record_id, DbClientPtr db, MetadataInfo metadata )
|
||||
{
|
||||
const auto simple_type { metadata.m_simple_type };
|
||||
|
||||
co_await db->execSqlCoro(
|
||||
"INSERT INTO metadata (record_id, simple_mime_type) VALUES ($1, $2) "
|
||||
"ON CONFLICT (record_id) DO UPDATE SET simple_mime_type = $2",
|
||||
record_id,
|
||||
simple_type );
|
||||
|
||||
Json::Value json {};
|
||||
Json::Reader reader {};
|
||||
if ( !metadata.m_extra.empty() )
|
||||
{
|
||||
if ( !reader.parse( metadata.m_extra, json ) )
|
||||
co_return std::unexpected( createBadRequest( "Failed to parse metadata \"{}\"", metadata.m_extra ) );
|
||||
|
||||
co_await db->execSqlCoro(
|
||||
"UPDATE metadata SET json = $2 WHERE record_id = $1", record_id, json.toStyledString() );
|
||||
}
|
||||
|
||||
switch ( simple_type )
|
||||
{
|
||||
case SimpleMimeType::IMAGE_PROJECT:
|
||||
{
|
||||
const auto& project_metadata { std::get< MetadataInfoImageProject >( metadata.m_metadata ) };
|
||||
co_await db->execSqlCoro(
|
||||
"INSERT INTO image_project_metadata (record_id, width, height, channels, layers) VALUES ($1, $2, $3, $4, $5) "
|
||||
"ON CONFLICT (record_id) DO UPDATE SET width = $2, height = $3, channels = $4, layers = $5",
|
||||
record_id,
|
||||
project_metadata.image_info.width,
|
||||
project_metadata.image_info.height,
|
||||
static_cast< SmallInt >( project_metadata.image_info.channels ),
|
||||
static_cast< SmallInt >( project_metadata.layers ) );
|
||||
|
||||
break;
|
||||
}
|
||||
case SimpleMimeType::IMAGE:
|
||||
{
|
||||
const auto& image_metadata { std::get< MetadataInfoImage >( metadata.m_metadata ) };
|
||||
co_await db->execSqlCoro(
|
||||
"INSERT INTO image_metadata (record_id, width, height, channels) VALUES ($1, $2, $3, $4) "
|
||||
"ON CONFLICT (record_id) DO UPDATE SET width = $2, height = $3, channels = $4",
|
||||
record_id,
|
||||
image_metadata.width,
|
||||
image_metadata.height,
|
||||
static_cast< std::uint16_t >( image_metadata.channels ) );
|
||||
|
||||
break;
|
||||
}
|
||||
case SimpleMimeType::VIDEO:
|
||||
{
|
||||
const auto& video_metadata { std::get< MetadataInfoVideo >( metadata.m_metadata ) };
|
||||
co_await db->execSqlCoro(
|
||||
"INSERT INTO video_metadata (record_id, width, height, bitrate, duration, framerate, has_audio) VALUES ($1, $2, $3, $4, $5, $6, $7) "
|
||||
"ON CONFLICT (record_id) DO UPDATE SET width = $2, height = $3, bitrate = $4, duration = $5, framerate = $6, has_audio = $7",
|
||||
record_id,
|
||||
video_metadata.m_width,
|
||||
video_metadata.m_height,
|
||||
video_metadata.m_bitrate,
|
||||
video_metadata.m_duration,
|
||||
video_metadata.m_fps,
|
||||
video_metadata.m_has_audio );
|
||||
|
||||
break;
|
||||
}
|
||||
case SimpleMimeType::ANIMATION:
|
||||
FGL_UNIMPLEMENTED();
|
||||
break;
|
||||
case SimpleMimeType::AUDIO:
|
||||
FGL_UNIMPLEMENTED();
|
||||
break;
|
||||
|
||||
case SimpleMimeType::NONE:
|
||||
break;
|
||||
default:
|
||||
FGL_UNIMPLEMENTED();
|
||||
}
|
||||
|
||||
co_return {};
|
||||
}
|
||||
|
||||
} // namespace idhan::metadata
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user