21 Commits

Author SHA1 Message Date
8c0e339aab Hardcode uid, gid in Dockerfile 2026-02-02 00:48:32 +03:00
e463e880be Replace properties config with yaml 2026-02-01 22:41:47 +03:00
58f876bf7f Add actuator http endpoints 2026-02-01 22:21:07 +03:00
8bcee3a225 Delete sourcecraft ci config 2026-02-01 20:38:40 +03:00
Maksim Pischulenok
61a6b6608e Merge pull request #8 from pischule/mise-ci
Use mise for environment setup
2025-12-21 13:08:16 +03:00
f055a7f82f Use mise for environment setup 2025-12-21 13:06:18 +03:00
f6c90b071e Test TelegramHelper 2025-12-21 12:59:11 +03:00
8291c72b0d Add "true" to confirm commands list 2025-12-16 22:36:11 +03:00
Maksim Pischulenok
20e6c61f31 Merge pull request #7 from pischule/split-ci-workflow
Split single ci workflow into two
2025-12-09 21:35:25 +03:00
99188bea32 Split single ci workflow into two 2025-12-09 21:33:37 +03:00
c791db409a Make q button toggle qr visibility 2025-12-07 19:22:05 +03:00
b31bd87687 Make qr code alt text invisible
Skip transcoding if audio/video already has desired encoding
2025-12-07 19:05:01 +03:00
061743492e Optimize transcoding
Skip transcoding if audio/video already has desired encoding
2025-12-07 19:00:47 +03:00
1266b0f440 Migrate from minio to aws s3 sdk 2025-12-07 17:55:06 +03:00
23dcd998bc Upgrade ktfmt 2025-12-07 16:02:20 +03:00
8337df89e6 Upgrade dependencies 2025-12-07 16:01:20 +03:00
702444d016 Improve docs 2025-12-07 15:28:19 +03:00
70bec5ea4f Disable json logs 2025-10-12 18:11:09 +03:00
6dae22ad14 Improve Dockerfile 2025-10-12 18:10:56 +03:00
2aa8dee019 Update mise config 2025-10-12 18:10:18 +03:00
Maksim Pischulenok
d7656e7675 Merge pull request #5 from pischule/pischule-license
Add LICENSE
2025-09-08 08:48:43 +03:00
27 changed files with 439 additions and 199 deletions

View File

@@ -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
View 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
View File

@@ -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

View File

@@ -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

View File

@@ -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"]

View File

@@ -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
![structurizr-1-ContainerDiagram.svg](docs/structurizr/out/structurizr-1-ContainerDiagram.svg)
## 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

View File

@@ -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
View File

@@ -0,0 +1,3 @@
.structurizr
workspace.json
!out

View File

@@ -0,0 +1,4 @@
#!/usr/bin/env sh
docker run -it --rm -p 8080:8080 -v "${PWD}:/usr/local/structurizr" structurizr/lite

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

View 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
View File

@@ -0,0 +1 @@
org.gradle.configuration-cache=true

View File

@@ -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

View File

@@ -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'

View File

@@ -4,4 +4,10 @@ dependencyResolutionManagement {
repositories { repositories {
mavenCentral() mavenCentral()
} }
versionCatalogs {
create("awssdk") {
from("aws.sdk.kotlin:version-catalog:1.5.97")
}
}
} }

View File

@@ -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(

View File

@@ -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) {

View File

@@ -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" }
} }
} }

View File

@@ -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
}
} }

View File

@@ -7,8 +7,18 @@ 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

View File

@@ -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")
}
} }

View File

@@ -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=<?>

View 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: <?>

View File

@@ -1,2 +0,0 @@
spring.application.name=memevizor
logging.structured.format.console=ecs

View File

@@ -0,0 +1,2 @@
spring.application.name: memevizor
management.server.port: 7900

View File

@@ -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>

View File

@@ -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()
}
}