mirror of
https://github.com/pischule/memevizor.git
synced 2026-02-04 09:00:52 +00:00
Compare commits
21 Commits
pischule-l
...
8c0e339aab
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c0e339aab | |||
| e463e880be | |||
| 58f876bf7f | |||
| 8bcee3a225 | |||
|
|
61a6b6608e | ||
| f055a7f82f | |||
| f6c90b071e | |||
| 8291c72b0d | |||
|
|
20e6c61f31 | ||
| 99188bea32 | |||
| c791db409a | |||
| b31bd87687 | |||
| 061743492e | |||
| 1266b0f440 | |||
| 23dcd998bc | |||
| 8337df89e6 | |||
| 702444d016 | |||
| 70bec5ea4f | |||
| 6dae22ad14 | |||
| 2aa8dee019 | |||
|
|
d7656e7675 |
@@ -1,37 +1,13 @@
|
|||||||
name: CI
|
name: release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
pull_request:
|
|
||||||
branches: [ main ]
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
|
||||||
name: Run Tests
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up JDK 21
|
|
||||||
uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: 'temurin'
|
|
||||||
java-version: '21'
|
|
||||||
cache: 'gradle'
|
|
||||||
|
|
||||||
- name: Test with Gradle
|
|
||||||
run: ./gradlew check
|
|
||||||
|
|
||||||
build-and-publish:
|
build-and-publish:
|
||||||
name: Build and Publish
|
name: Build and Publish
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -40,12 +16,9 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up JDK 21
|
- uses: jdx/mise-action@v3
|
||||||
uses: actions/setup-java@v4
|
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
version: 2025.12.12
|
||||||
java-version: '21'
|
|
||||||
cache: 'gradle'
|
|
||||||
|
|
||||||
- name: Build with Gradle
|
- name: Build with Gradle
|
||||||
run: ./gradlew build
|
run: ./gradlew build
|
||||||
18
.github/workflows/test.yaml
vendored
Normal file
18
.github/workflows/test.yaml
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
name: test
|
||||||
|
|
||||||
|
on: [push]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Run Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- uses: jdx/mise-action@v3
|
||||||
|
with:
|
||||||
|
version: 2025.12.12
|
||||||
|
- name: Test with Gradle
|
||||||
|
run: mise run test
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -41,6 +41,7 @@ out/
|
|||||||
|
|
||||||
|
|
||||||
src/main/resources/application-local.properties
|
src/main/resources/application-local.properties
|
||||||
|
src/main/resources/application-local.yaml
|
||||||
|
|
||||||
src/main/resources/static
|
src/main/resources/static
|
||||||
!src/main/resources/static/index.html
|
!src/main/resources/static/index.html
|
||||||
|
|||||||
31
.src.ci.yaml
31
.src.ci.yaml
@@ -1,31 +0,0 @@
|
|||||||
on:
|
|
||||||
push:
|
|
||||||
- workflows: build-package-workflow
|
|
||||||
filter:
|
|
||||||
branches: ["main"]
|
|
||||||
pull_request:
|
|
||||||
- workflows: build-package-workflow
|
|
||||||
filter:
|
|
||||||
source_branches: ["**", "!test**"]
|
|
||||||
target_branches: "main"
|
|
||||||
|
|
||||||
workflows:
|
|
||||||
build-package-workflow:
|
|
||||||
tasks:
|
|
||||||
- build-package-task
|
|
||||||
|
|
||||||
tasks:
|
|
||||||
- name: build-package-task
|
|
||||||
cubes:
|
|
||||||
- name: setup-jdk
|
|
||||||
script:
|
|
||||||
- sudo apt install openjdk-17-jdk -y
|
|
||||||
- name: test
|
|
||||||
script:
|
|
||||||
- ./gradlew check
|
|
||||||
- name: package
|
|
||||||
script:
|
|
||||||
- ./gradlew assemble
|
|
||||||
artifacts:
|
|
||||||
paths:
|
|
||||||
- build/libs/memevizor-0.0.1-SNAPSHOT.jar
|
|
||||||
22
Dockerfile
22
Dockerfile
@@ -1,11 +1,23 @@
|
|||||||
FROM docker.io/eclipse-temurin:21
|
FROM docker.io/eclipse-temurin:21
|
||||||
|
|
||||||
|
ARG USER_ID=10001
|
||||||
|
ARG GROUP_ID=10001
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y ffmpeg \
|
&& apt-get install -y ffmpeg \
|
||||||
&& apt-get clean \
|
&& apt-get clean \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
RUN groupadd -r spring && useradd --no-log-init -r -g spring spring
|
|
||||||
USER spring:spring
|
RUN groupadd -g ${GROUP_ID} app \
|
||||||
|
&& useradd --no-log-init -u ${USER_ID} -g app --shell /sbin/nologin app
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
ARG JAR_FILE=build/libs/*.jar
|
ARG JAR_FILE=build/libs/*.jar
|
||||||
COPY ${JAR_FILE} app.jar
|
COPY --chown=app:app ${JAR_FILE} app.jar
|
||||||
COPY build/resources/main/static BOOT-INF/classes/static
|
COPY --chown=app:app build/resources/main/static BOOT-INF/classes/static
|
||||||
ENTRYPOINT ["java","-jar","/app.jar"]
|
|
||||||
|
USER ${USER_ID}:${GROUP_ID}
|
||||||
|
|
||||||
|
ENTRYPOINT ["java","-jar","app.jar"]
|
||||||
|
|
||||||
|
|||||||
75
README.md
75
README.md
@@ -2,12 +2,24 @@
|
|||||||
|
|
||||||
A Telegram bot for meme management with cloud storage integration and a web interface for display.
|
A Telegram bot for meme management with cloud storage integration and a web interface for display.
|
||||||
|
|
||||||
## Features
|
## Core Functionality (User Stories)
|
||||||
- 🤖 Telegram bot that handles image and video memes
|
|
||||||
- ☁️ Cloud storage integration (AWS S3/Yandex Cloud compatible)
|
### Suggesting a Meme (User)
|
||||||
- 🖼️ Web interface with auto-refreshing media display
|
|
||||||
- 🔐 Approver user system for content moderation
|
* User scans QR code.
|
||||||
- 📁 Local development mode with dummy storage
|
* User sends media (video/picture) to the bot.
|
||||||
|
* Bot forwards the media to the **Approver Group**.
|
||||||
|
|
||||||
|
### Approving a Meme (Approver)
|
||||||
|
|
||||||
|
* **Approver** replies to media in the **Approver Group** with an approval command (e.g., `!SOXOK`, `this`).
|
||||||
|
* **Processing:** If video, bot re-encodes to WebM (AV1 + Opus).
|
||||||
|
* Bot uploads the final media to **S3**.
|
||||||
|
* **Client Update:** Frontend detects S3 resource change and downloads the latest media.
|
||||||
|
|
||||||
|
### Direct Meme Submission (Approver)
|
||||||
|
|
||||||
|
* **Approver** sends media directly to the **Approver Group** or the bot.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
The system consists of:
|
The system consists of:
|
||||||
@@ -16,46 +28,29 @@ The system consists of:
|
|||||||
- **Web Interface**: Simple HTML page for viewing memes
|
- **Web Interface**: Simple HTML page for viewing memes
|
||||||
- **Spring Boot**: Kotlin-based backend with dependency injection
|
- **Spring Boot**: Kotlin-based backend with dependency injection
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
1. Create `.env` file with:
|
|
||||||
```properties
|
|
||||||
# Telegram Bot Configuration
|
|
||||||
BOT_TOKEN=your_bot_token
|
|
||||||
FORWARD_CHAT_ID=your_forward_chat_id
|
|
||||||
APPROVER_USER_IDS=id1,id2,id3
|
|
||||||
|
|
||||||
# S3 Configuration (for production)
|
The application is configured via Spring Boot properties.
|
||||||
S3_ACCESS_KEY_ID=your_key
|
|
||||||
S3_SECRET_ACCESS_KEY=your_secret
|
|
||||||
S3_BUCKET=your_bucket_name
|
|
||||||
```
|
|
||||||
|
|
||||||
2. For local development:
|
|
||||||
```bash
|
|
||||||
./gradlew bootRun --args='--spring.profiles.active=local'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
1. Send a meme (image/video) to the Telegram bot
|
|
||||||
2. Reply with `this` or `!soxok` to approve and upload
|
|
||||||
3. View the meme at `http://localhost:8080` (or your deployed URL)
|
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
```bash
|
|
||||||
docker build -t memevizor .
|
|
||||||
docker run -d -p 8080:8080 memevizor
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cloud (Heroku/AWS/etc)
|
1. Run the container with environment variables:
|
||||||
1. Set environment variables
|
```bash
|
||||||
2. Deploy with your preferred provider
|
docker run -d \
|
||||||
|
-e BOT_TOKEN='your_bot_token' \
|
||||||
## Contributing
|
-e BOT_FORWARD_CHAT_ID='your_forward_chat_id' \
|
||||||
Contributions welcome! Please:
|
-e BOT_APPROVER_USER_IDS='id1,id2,id3' \
|
||||||
1. Fork the repository
|
-e S3_ENDPOINT='https.s3.example.com' \
|
||||||
2. Create a feature branch
|
-e S3_REGION='us-east-1' \
|
||||||
3. Submit a pull request
|
-e S3_BUCKET='your_bucket_name' \
|
||||||
|
-e S3_ACCESS_KEY_ID='your_key' \
|
||||||
|
-e S3_SECRET_ACCESS_KEY='your_secret' \
|
||||||
|
ghcr.io/pischule/memevizor:latest
|
||||||
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
GNU GPLv3
|
GNU GPLv3
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
plugins {
|
plugins {
|
||||||
kotlin("jvm") version "2.2.0"
|
kotlin("jvm") version "2.2.21"
|
||||||
kotlin("plugin.spring") version "2.2.0"
|
kotlin("plugin.spring") version "2.2.21"
|
||||||
id("org.springframework.boot") version "3.4.4"
|
id("org.springframework.boot") version "4.0.0"
|
||||||
id("io.spring.dependency-management") version "1.1.7"
|
id("io.spring.dependency-management") version "1.1.7"
|
||||||
id("com.diffplug.spotless") version "7.0.3"
|
id("com.diffplug.spotless") version "8.1.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
group = "com.pischule"
|
group = "com.pischule"
|
||||||
@@ -31,11 +31,14 @@ dependencies {
|
|||||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
||||||
implementation("io.github.kotlin-telegram-bot.kotlin-telegram-bot:telegram:6.3.0")
|
implementation("io.github.kotlin-telegram-bot.kotlin-telegram-bot:telegram:6.3.0")
|
||||||
implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
|
implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
|
||||||
implementation("io.minio:minio:8.5.17")
|
implementation(awssdk.services.s3)
|
||||||
|
implementation("org.springframework.boot:spring-boot-starter-actuator")
|
||||||
|
implementation("org.springframework.boot:spring-boot-starter-webmvc")
|
||||||
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
||||||
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
|
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
|
||||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||||
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
|
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
|
||||||
|
testImplementation("io.kotest:kotest-assertions-core:6.0.7")
|
||||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +54,6 @@ tasks.withType<Test> {
|
|||||||
|
|
||||||
spotless {
|
spotless {
|
||||||
kotlin {
|
kotlin {
|
||||||
ktfmt("0.54").kotlinlangStyle()
|
ktfmt("0.59").kotlinlangStyle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
docs/structurizr/.gitignore
vendored
Normal file
3
docs/structurizr/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.structurizr
|
||||||
|
workspace.json
|
||||||
|
!out
|
||||||
4
docs/structurizr/launch-structurizr.sh
Executable file
4
docs/structurizr/launch-structurizr.sh
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
docker run -it --rm -p 8080:8080 -v "${PWD}:/usr/local/structurizr" structurizr/lite
|
||||||
|
|
||||||
1
docs/structurizr/out/structurizr-1-ContainerDiagram.svg
Normal file
1
docs/structurizr/out/structurizr-1-ContainerDiagram.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 20 KiB |
50
docs/structurizr/workspace.dsl
Normal file
50
docs/structurizr/workspace.dsl
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
workspace "memevizor" "A meme sharing service" {
|
||||||
|
model {
|
||||||
|
// Actors
|
||||||
|
u = person "User" "Meme submitter."
|
||||||
|
a = person "Approver" "Moderates/Approves memes."
|
||||||
|
tv = person "Viewer (Any Web Browser)" "Opens the S3-hosted UI on a browser (e.g., on a TV screen) to view the current approved meme."
|
||||||
|
|
||||||
|
// External Systems
|
||||||
|
tg = softwareSystem "Telegram" "Messaging platform used for content submission and moderation."
|
||||||
|
|
||||||
|
// The Software System under development
|
||||||
|
ss = softwareSystem "Memevizor" "A Telegram-based service for submitting, approving, and displaying memes." {
|
||||||
|
|
||||||
|
// Core Application Container (Renamed and specified technology)
|
||||||
|
bot = container "Memevizor Bot Backend" "Kotlin/Spring Application." "Processes Telegram updates, handles media conversion, and manages S3 content."
|
||||||
|
|
||||||
|
// Storage Container (Clarified role)
|
||||||
|
s3 = container "Content S3 Bucket" "AWS S3." "Stores static UI assets (HTML/CSS/JS) and approved media content."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actor Relationships
|
||||||
|
u -> tg "Sends memes and content to"
|
||||||
|
a -> tg "Moderates/Approves content by replying to messages in"
|
||||||
|
|
||||||
|
// Backend Interactions
|
||||||
|
// 1. Core Telegram Communication (Polling for messages and sending replies)
|
||||||
|
bot -> tg "Polls for updates and sends reactions/replies/Downloads media" "HTTPS/Telegram Bot API"
|
||||||
|
|
||||||
|
// 3. S3 Interactions
|
||||||
|
bot -> s3 "Uploads UI assets/media" "HTTPS"
|
||||||
|
|
||||||
|
// Viewer/Client Interactions
|
||||||
|
tv -> s3 "Fetches UI and polls for media files" "HTTPS"
|
||||||
|
}
|
||||||
|
|
||||||
|
views {
|
||||||
|
systemContext ss "SystemContext" {
|
||||||
|
// Focus on the Memevizor system and its neighbors
|
||||||
|
include *
|
||||||
|
autolayout lr
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
container ss "ContainerDiagram" {
|
||||||
|
// Focus on the containers within Memevizor and their external dependencies
|
||||||
|
include *
|
||||||
|
autolayout tb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
gradle.properties
Normal file
1
gradle.properties
Normal file
@@ -0,0 +1 @@
|
|||||||
|
org.gradle.configuration-cache=true
|
||||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
12
mise.toml
12
mise.toml
@@ -1,14 +1,10 @@
|
|||||||
[tools]
|
[tools]
|
||||||
java = "temurin-21"
|
java = "temurin-21"
|
||||||
|
|
||||||
[tasks.release]
|
|
||||||
description = 'Build and push docker image'
|
|
||||||
run = [
|
|
||||||
'./gradlew build',
|
|
||||||
'docker build -t memevizor:latest .',
|
|
||||||
'docker pussh memevizor:latest apps0.pischule.com'
|
|
||||||
]
|
|
||||||
|
|
||||||
[tasks.fmt]
|
[tasks.fmt]
|
||||||
description = 'Fourmat source code'
|
description = 'Fourmat source code'
|
||||||
run = './gradlew :spotlessApply'
|
run = './gradlew :spotlessApply'
|
||||||
|
|
||||||
|
[tasks.test]
|
||||||
|
description = 'Run tests'
|
||||||
|
run = './gradlew check'
|
||||||
|
|||||||
@@ -4,4 +4,10 @@ dependencyResolutionManagement {
|
|||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
versionCatalogs {
|
||||||
|
create("awssdk") {
|
||||||
|
from("aws.sdk.kotlin:version-catalog:1.5.97")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -46,6 +46,7 @@ class BotConfiguration(
|
|||||||
photos { handleMedia() }
|
photos { handleMedia() }
|
||||||
video { handleMedia() }
|
video { handleMedia() }
|
||||||
videoNote { handleMedia() }
|
videoNote { handleMedia() }
|
||||||
|
document { handleMedia() }
|
||||||
command("whoami") {
|
command("whoami") {
|
||||||
handleMessage(message) {
|
handleMessage(message) {
|
||||||
bot.sendMessage(
|
bot.sendMessage(
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class ThisCommandHandlerService(
|
|||||||
private val fileUploaderService: FileUploaderService,
|
private val fileUploaderService: FileUploaderService,
|
||||||
private val videoTranscoderService: VideoTranscoderService,
|
private val videoTranscoderService: VideoTranscoderService,
|
||||||
) {
|
) {
|
||||||
private val confirmCommands = listOf("this", "!soxok")
|
private val confirmCommands = listOf("this", "true", "!soxok")
|
||||||
private val mediaFileName = "media"
|
private val mediaFileName = "media"
|
||||||
|
|
||||||
fun create(env: MessageHandlerEnvironment) {
|
fun create(env: MessageHandlerEnvironment) {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package com.pischule.memevizor.upload
|
package com.pischule.memevizor.upload
|
||||||
|
|
||||||
|
import aws.sdk.kotlin.services.s3.S3Client
|
||||||
|
import aws.sdk.kotlin.services.s3.model.PutObjectRequest
|
||||||
|
import aws.smithy.kotlin.runtime.content.ByteStream
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import io.github.oshai.kotlinlogging.withLoggingContext
|
import io.github.oshai.kotlinlogging.withLoggingContext
|
||||||
import io.minio.MinioClient
|
import kotlinx.coroutines.runBlocking
|
||||||
import io.minio.PutObjectArgs
|
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
|
||||||
@@ -11,17 +13,18 @@ private val logger = KotlinLogging.logger {}
|
|||||||
|
|
||||||
@ConditionalOnBean(S3Config::class)
|
@ConditionalOnBean(S3Config::class)
|
||||||
@Service
|
@Service
|
||||||
class FileUploaderService(private val s3Client: MinioClient, private val s3Props: S3Props) {
|
class FileUploaderService(private val s3Client: S3Client, private val s3Props: S3Props) {
|
||||||
fun uploadFile(fileBytes: ByteArray, filename: String, contentType: String) {
|
fun uploadFile(fileBytes: ByteArray, filename: String, contentType: String) {
|
||||||
withLoggingContext("filename" to filename, "bucket" to s3Props.bucket) {
|
withLoggingContext("filename" to filename, "bucket" to s3Props.bucket) {
|
||||||
s3Client.putObject(
|
val request = PutObjectRequest {
|
||||||
PutObjectArgs.builder()
|
bucket = s3Props.bucket
|
||||||
.bucket(s3Props.bucket)
|
key = filename
|
||||||
.`object`(filename)
|
body = ByteStream.fromBytes(fileBytes)
|
||||||
.stream(fileBytes.inputStream(), fileBytes.size.toLong(), -1)
|
this.contentType = contentType
|
||||||
.contentType(contentType)
|
}
|
||||||
.build()
|
|
||||||
)
|
logger.info { "Started uploading a file" }
|
||||||
|
runBlocking { s3Client.putObject(request) }
|
||||||
logger.info { "Uploaded a file to S3" }
|
logger.info { "Uploaded a file to S3" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.pischule.memevizor.upload
|
package com.pischule.memevizor.upload
|
||||||
|
|
||||||
import io.minio.MinioClient
|
import aws.sdk.kotlin.runtime.auth.credentials.StaticCredentialsProvider
|
||||||
|
import aws.sdk.kotlin.services.s3.S3Client
|
||||||
|
import aws.smithy.kotlin.runtime.net.url.Url
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties
|
import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||||
import org.springframework.context.annotation.Bean
|
import org.springframework.context.annotation.Bean
|
||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.Configuration
|
||||||
@@ -8,11 +10,17 @@ import org.springframework.context.annotation.Configuration
|
|||||||
@EnableConfigurationProperties(S3Props::class)
|
@EnableConfigurationProperties(S3Props::class)
|
||||||
@Configuration
|
@Configuration
|
||||||
class S3Config {
|
class S3Config {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
fun s3Client(s3Props: S3Props): MinioClient =
|
fun s3Client(s3Props: S3Props) = S3Client {
|
||||||
MinioClient.builder()
|
endpointUrl = Url.parse(s3Props.endpoint)
|
||||||
.endpoint(s3Props.endpoint)
|
region = s3Props.region
|
||||||
.region(s3Props.region)
|
credentialsProvider = StaticCredentialsProvider {
|
||||||
.credentials(s3Props.accessKeyId, s3Props.secretAccessKey)
|
accessKeyId = s3Props.accessKeyId
|
||||||
.build()
|
secretAccessKey = s3Props.secretAccessKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// not supported by Yandex Object Storage
|
||||||
|
continueHeaderThresholdBytes = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,20 @@ fun Message.getMedia(): MessageMedia? {
|
|||||||
return MessageMedia(fileId, MessageMedia.Type.PHOTO)
|
return MessageMedia(fileId, MessageMedia.Type.PHOTO)
|
||||||
}
|
}
|
||||||
|
|
||||||
(video?.fileId ?: videoNote?.fileId)?.let { fileId ->
|
video?.let {
|
||||||
return MessageMedia(fileId, MessageMedia.Type.VIDEO)
|
return MessageMedia(it.fileId, MessageMedia.Type.VIDEO)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
videoNote?.let {
|
||||||
|
return MessageMedia(it.fileId, MessageMedia.Type.VIDEO)
|
||||||
|
}
|
||||||
|
|
||||||
|
document
|
||||||
|
?.takeIf { it.mimeType?.startsWith("video/") == true }
|
||||||
|
?.let {
|
||||||
|
return MessageMedia(it.fileId, MessageMedia.Type.VIDEO)
|
||||||
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.pischule.memevizor.video
|
package com.pischule.memevizor.video
|
||||||
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import java.nio.file.Path
|
||||||
import kotlin.io.path.createTempFile
|
import kotlin.io.path.createTempFile
|
||||||
import kotlin.io.path.deleteIfExists
|
import kotlin.io.path.deleteIfExists
|
||||||
import kotlin.io.path.readBytes
|
import kotlin.io.path.readBytes
|
||||||
@@ -14,62 +15,32 @@ private val logger = KotlinLogging.logger {}
|
|||||||
class VideoTranscoderService {
|
class VideoTranscoderService {
|
||||||
|
|
||||||
fun transcode(inputVideo: ByteArray): ByteArray {
|
fun transcode(inputVideo: ByteArray): ByteArray {
|
||||||
logger.info { "Started transcoding a file" }
|
logger.info { "Started transcoding a video" }
|
||||||
|
|
||||||
val inputFile = createTempFile()
|
val inputFile = createTempFile()
|
||||||
val outputFile = createTempFile()
|
val outputFile = createTempFile()
|
||||||
|
|
||||||
val command: List<String> =
|
|
||||||
listOf(
|
|
||||||
"ffmpeg",
|
|
||||||
"-nostdin",
|
|
||||||
"-nostats",
|
|
||||||
"-hide_banner",
|
|
||||||
// input
|
|
||||||
"-i",
|
|
||||||
"$inputFile",
|
|
||||||
// video
|
|
||||||
"-map",
|
|
||||||
"0:v",
|
|
||||||
"-c:v",
|
|
||||||
"libsvtav1",
|
|
||||||
"-preset",
|
|
||||||
"6",
|
|
||||||
"-crf",
|
|
||||||
"35",
|
|
||||||
"-svtav1-params",
|
|
||||||
"film-grain=10",
|
|
||||||
// audio
|
|
||||||
"-map",
|
|
||||||
"0:a?",
|
|
||||||
"-c:a",
|
|
||||||
"libopus",
|
|
||||||
"-b:a",
|
|
||||||
"96k",
|
|
||||||
"-vbr",
|
|
||||||
"on",
|
|
||||||
"-compression_level",
|
|
||||||
"10",
|
|
||||||
// output
|
|
||||||
"-f",
|
|
||||||
"webm",
|
|
||||||
"-y",
|
|
||||||
"$outputFile",
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
inputFile.writeBytes(inputVideo)
|
inputFile.writeBytes(inputVideo)
|
||||||
|
|
||||||
val process = ProcessBuilder(command).redirectErrorStream(true).start()
|
val copyVideo = hasDesiredVideoEncoding(inputFile)
|
||||||
val exitCode: Int
|
val copyAudio = hasDesiredAudioEncoding(inputFile)
|
||||||
val timeTaken = measureTime { exitCode = process.waitFor() }
|
|
||||||
val processOutput = process.inputStream.bufferedReader().use { it.readText() }
|
val processOutput: String
|
||||||
if (exitCode != 0) {
|
val timeTaken = measureTime {
|
||||||
throw VideoTranscodingException(exitCode, processOutput)
|
processOutput =
|
||||||
|
launchCommand(
|
||||||
|
transcodeCommand(
|
||||||
|
inputFile = inputFile,
|
||||||
|
outputFile = outputFile,
|
||||||
|
copyVideo = copyVideo,
|
||||||
|
copyAudio = copyAudio,
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.atInfo {
|
logger.atInfo {
|
||||||
message = "Finished transcoding a file"
|
message = "Finished transcoding a video"
|
||||||
payload =
|
payload =
|
||||||
mapOf(
|
mapOf(
|
||||||
"processOutput" to processOutput,
|
"processOutput" to processOutput,
|
||||||
@@ -82,4 +53,106 @@ class VideoTranscoderService {
|
|||||||
outputFile.deleteIfExists()
|
outputFile.deleteIfExists()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun launchCommand(command: List<String>): String {
|
||||||
|
val process = ProcessBuilder(command).redirectErrorStream(true).start()
|
||||||
|
val exitCode = process.waitFor()
|
||||||
|
val processOutput = process.inputStream.bufferedReader().use { it.readText() }
|
||||||
|
if (exitCode != 0) {
|
||||||
|
throw VideoTranscodingException(exitCode, processOutput)
|
||||||
|
}
|
||||||
|
return processOutput.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasDesiredVideoEncoding(inputFile: Path): Boolean {
|
||||||
|
val videoCodec = launchCommand(getVideoCodecCommand(inputFile))
|
||||||
|
return videoCodec == "av1"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasDesiredAudioEncoding(inputFile: Path): Boolean {
|
||||||
|
val audioCodec = launchCommand(getAudioCodecCommand(inputFile))
|
||||||
|
return audioCodec == "opus" || audioCodec == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getVideoCodecCommand(inputFile: Path) =
|
||||||
|
listOf(
|
||||||
|
"ffprobe",
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-select_streams",
|
||||||
|
"v:0",
|
||||||
|
"-show_entries",
|
||||||
|
"stream=codec_name",
|
||||||
|
"-of",
|
||||||
|
"default=noprint_wrappers=1:nokey=1",
|
||||||
|
"$inputFile",
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun getAudioCodecCommand(inputFile: Path) =
|
||||||
|
listOf(
|
||||||
|
"ffprobe",
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-select_streams",
|
||||||
|
"a:0",
|
||||||
|
"-show_entries",
|
||||||
|
"stream=codec_name",
|
||||||
|
"-of",
|
||||||
|
"default=noprint_wrappers=1:nokey=1",
|
||||||
|
"$inputFile",
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun transcodeCommand(
|
||||||
|
inputFile: Path,
|
||||||
|
outputFile: Path,
|
||||||
|
copyVideo: Boolean,
|
||||||
|
copyAudio: Boolean,
|
||||||
|
): List<String> = buildList {
|
||||||
|
add("ffmpeg")
|
||||||
|
add("-nostdin")
|
||||||
|
add("-nostats")
|
||||||
|
add("-hide_banner")
|
||||||
|
|
||||||
|
// input
|
||||||
|
add("-i")
|
||||||
|
add("$inputFile")
|
||||||
|
|
||||||
|
// video
|
||||||
|
add("-map")
|
||||||
|
add("0:v")
|
||||||
|
add("-c:v")
|
||||||
|
if (copyVideo) {
|
||||||
|
add("copy")
|
||||||
|
} else {
|
||||||
|
add("libsvtav1")
|
||||||
|
add("-preset")
|
||||||
|
add("6")
|
||||||
|
add("-crf")
|
||||||
|
add("35")
|
||||||
|
add("-svtav1-params")
|
||||||
|
add("film-grain=10")
|
||||||
|
}
|
||||||
|
|
||||||
|
// audio
|
||||||
|
add("-map")
|
||||||
|
add("0:a?")
|
||||||
|
add("-c:a")
|
||||||
|
if (copyAudio) {
|
||||||
|
add("copy")
|
||||||
|
} else {
|
||||||
|
add("libopus")
|
||||||
|
add("-b:a")
|
||||||
|
add("96k")
|
||||||
|
add("-vbr")
|
||||||
|
add("on")
|
||||||
|
add("-compression_level")
|
||||||
|
add("10")
|
||||||
|
}
|
||||||
|
|
||||||
|
// output
|
||||||
|
add("-f")
|
||||||
|
add("webm")
|
||||||
|
add("-y")
|
||||||
|
add("$outputFile")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
bot.forward-chat-id=<?>
|
|
||||||
bot.approver-user-ids[0]=<?>
|
|
||||||
bot.token=
|
|
||||||
s3.endpoint=https://storage.yandexcloud.net
|
|
||||||
s3.region=ru-central1
|
|
||||||
s3.bucket=memevizor-test
|
|
||||||
s3.access-key-id=<?>
|
|
||||||
s3.secret-access-key=<?>
|
|
||||||
11
src/main/resources/application-local.yaml.dist
Normal file
11
src/main/resources/application-local.yaml.dist
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
bot:
|
||||||
|
forward-chat-id: <?>
|
||||||
|
approver-user-ids:
|
||||||
|
- <?>
|
||||||
|
token: <?>
|
||||||
|
s3:
|
||||||
|
endpoint: https://storage.yandexcloud.net
|
||||||
|
region: ru-central1
|
||||||
|
bucket: memevizor-test
|
||||||
|
access-key-id: <?>
|
||||||
|
secret-access-key: <?>
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
spring.application.name=memevizor
|
|
||||||
logging.structured.format.console=ecs
|
|
||||||
2
src/main/resources/application.yaml
Normal file
2
src/main/resources/application.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
spring.application.name: memevizor
|
||||||
|
management.server.port: 7900
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#qr-container {
|
#qr-container {
|
||||||
|
font-size: 0;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: var(--qr-margin);
|
bottom: var(--qr-margin);
|
||||||
right: var(--qr-margin);
|
right: var(--qr-margin);
|
||||||
@@ -142,6 +143,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleQrVisibility() {
|
||||||
|
const qr = document.getElementById("qr-code");
|
||||||
|
const isVisible = qr.style.display === '';
|
||||||
|
qr.style.display = isVisible ? 'none' : '';
|
||||||
|
}
|
||||||
|
|
||||||
// --- Event Listeners ---
|
// --- Event Listeners ---
|
||||||
document.addEventListener('DOMContentLoaded', refreshMedia); // Initial fetch
|
document.addEventListener('DOMContentLoaded', refreshMedia); // Initial fetch
|
||||||
setInterval(refreshMedia, refreshIntervalMs); // Periodic refresh
|
setInterval(refreshMedia, refreshIntervalMs); // Periodic refresh
|
||||||
@@ -152,6 +159,10 @@
|
|||||||
event.preventDefault(); // Prevent page scroll
|
event.preventDefault(); // Prevent page scroll
|
||||||
refreshMedia();
|
refreshMedia();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.key === 'q') {
|
||||||
|
toggleQrVisibility()
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package com.pischule.memevizor.util
|
||||||
|
|
||||||
|
import com.github.kotlintelegrambot.entities.Chat
|
||||||
|
import com.github.kotlintelegrambot.entities.Message
|
||||||
|
import com.github.kotlintelegrambot.entities.files.Document
|
||||||
|
import com.github.kotlintelegrambot.entities.files.PhotoSize
|
||||||
|
import com.github.kotlintelegrambot.entities.files.Video
|
||||||
|
import com.github.kotlintelegrambot.entities.files.VideoNote
|
||||||
|
import io.kotest.matchers.nulls.shouldBeNull
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class TelegramHelperTest {
|
||||||
|
private val mockChat = Chat(id = 1L, type = "private")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getMedia should return photo from PhotoSize`() {
|
||||||
|
val message =
|
||||||
|
Message(
|
||||||
|
messageId = 1L,
|
||||||
|
chat = mockChat,
|
||||||
|
date = 123,
|
||||||
|
photo = listOf(PhotoSize("p1", "p1u", 100, 100), PhotoSize("p2", "p2u", 200, 200)),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = message.getMedia()
|
||||||
|
|
||||||
|
result shouldBe MessageMedia(fileId = "p2", type = MessageMedia.Type.PHOTO)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getMedia should return video from Video`() {
|
||||||
|
val message =
|
||||||
|
Message(
|
||||||
|
messageId = 1L,
|
||||||
|
chat = mockChat,
|
||||||
|
date = 123,
|
||||||
|
video = Video("v1", "v1u", 100, 100, 10),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = message.getMedia()
|
||||||
|
|
||||||
|
result shouldBe MessageMedia(fileId = "v1", type = MessageMedia.Type.VIDEO)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getMedia should return video from VideoNote`() {
|
||||||
|
val message =
|
||||||
|
Message(
|
||||||
|
messageId = 1L,
|
||||||
|
chat = mockChat,
|
||||||
|
date = 123,
|
||||||
|
videoNote = VideoNote("vn1", "vn1u", 100, 10),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = message.getMedia()
|
||||||
|
|
||||||
|
result shouldBe MessageMedia(fileId = "vn1", type = MessageMedia.Type.VIDEO)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getMedia should return video from Document with video mimeType`() {
|
||||||
|
val message =
|
||||||
|
Message(
|
||||||
|
messageId = 1L,
|
||||||
|
chat = mockChat,
|
||||||
|
date = 123,
|
||||||
|
document = Document("d1", "d1u", mimeType = "video/mp4"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = message.getMedia()
|
||||||
|
|
||||||
|
result shouldBe MessageMedia(fileId = "d1", type = MessageMedia.Type.VIDEO)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getMedia should return null for Document with non-video mimeType`() {
|
||||||
|
val message =
|
||||||
|
Message(
|
||||||
|
messageId = 1L,
|
||||||
|
chat = mockChat,
|
||||||
|
date = 123,
|
||||||
|
document = Document("d1", "d1u", mimeType = "image/jpeg"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = message.getMedia()
|
||||||
|
|
||||||
|
result.shouldBeNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getMedia should return null for message with no media`() {
|
||||||
|
val message = Message(messageId = 1L, chat = mockChat, date = 123, text = "hello")
|
||||||
|
|
||||||
|
val result = message.getMedia()
|
||||||
|
|
||||||
|
result.shouldBeNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user