14 Commits

Author SHA1 Message Date
511e62b58c sticky pagination
All checks were successful
Build and Push Image / build-and-push (push) Successful in 4m6s
2025-12-28 15:20:21 -05:00
41aa78b672 added pagination controls
All checks were successful
Build and Push Image / build-and-push (push) Successful in 4m33s
2025-12-28 14:29:46 -05:00
21163b277d update dockerfile
All checks were successful
Build and Push Image / build-and-push (push) Successful in 4m0s
2025-12-26 17:14:45 -05:00
7a0d3b7f40 merge
Some checks failed
Build and Push Image / build-and-push (push) Failing after 4m5s
2025-12-26 17:03:34 -05:00
6d2b8c77b2 update to dx 0.7.2 2025-12-26 17:03:00 -05:00
8bdd9edbb0 Increase multi-part body limit
All checks were successful
Build and Push Image / build-and-push (push) Successful in 3m52s
2025-12-22 15:55:46 +00:00
5e6b0b21a6 fix filename handling
All checks were successful
Build and Push Image / build-and-push (push) Successful in 3m48s
2025-12-14 16:22:51 -05:00
19274d444d fix typo 2025-12-14 16:09:11 -05:00
63e2f9f791 update dockerfile
All checks were successful
Build and Push Image / build-and-push (push) Successful in 3m41s
2025-12-14 16:07:53 -05:00
8964d1c069 testing using libvips 2025-08-17 14:57:21 -04:00
8808126905 testing using ffmpeg to generate thumbnails for avif 2025-08-17 03:16:19 -04:00
d36aaac836 empty 2025-08-13 23:14:21 -04:00
bb2c6c4683 removed woodpecker file 2025-08-13 21:57:33 -04:00
f8d457a096 moved woodpecker file to root 2025-08-13 21:51:10 -04:00
23 changed files with 2017 additions and 591 deletions

View File

@@ -1,46 +0,0 @@
pipeline:
build-and-push:
image: plugins/docker
settings:
dockerfile: AobaServer/Dockerfile
context: .
repo: git.kaisei.app/amatsugu/aoba
tags: latest
username:
from_secret: docker_user
password:
from_secret: docker_pass
deploy:
image: appleboy/ssh
settings:
host: your-app-host.internal
username: deploy
key:
from_secret: ssh_key
port: 22
script:
- docker pull git.kaisei.app/amatsugu/aoba:latest
# Run temporary container on docker network, no port binding
- docker run -d --rm \
--name aoba-temp \
--network aoba-net \
git.kaisei.app/amatsugu/aoba:latest
# Wait for it to become healthy
- sleep 3
- curl -f http://aoba-temp:8080 --connect-timeout 2 || (echo "Health check failed" && docker stop aoba-temp && exit 1)
# Stop old container (bound to host port)
- docker stop aoba || true
# Start new container on network and bind to host port 8080
- docker run -d --rm \
--name aoba \
--network aoba-net \
-p 9432:8080 \
git.kaisei.app/amatsugu/aoba:latest
# Stop temp container
- docker stop aoba-temp || true

View File

@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkou code
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Build

1961
AobaClient/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
dioxus = { version = "0.6.0", features = ["router"] }
dioxus = { version = "0.7.2", features = ["router"] }
serde = "1.0.219"
serde_repr = "0.1.20"
tonic = { version = "*", default-features = false, features = [

View File

@@ -21,6 +21,8 @@
top: 0;
position: sticky;
z-index: 100;
backdrop-filter: blur(20px);
box-shadow: 0 3px 10px $mainBGColor;
}
body {
@@ -195,3 +197,24 @@ form {
}
}
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 5;
padding: 10px;
a {
transition: all 0.25s ease-out;
cursor: pointer;
user-select: none;
padding: 5px 10px;
background-color: $featureColor;
border-radius: 4px;
&:hover {
background-color: $focusColor;
}
}
}

View File

@@ -9,6 +9,8 @@ use crate::{
#[derive(PartialEq, Clone, Props)]
pub struct MediaGridProps {
pub query: Option<String>,
pub max_page: Signal<i32>,
pub total_items: Signal<i32>,
#[props(default = Some(1))]
pub page: Option<i32>,
#[props(default = Some(100))]
@@ -33,12 +35,13 @@ impl Into<PageFilter> for MediaGridProps {
}
#[component]
pub fn MediaGrid(props: MediaGridProps) -> Element {
pub fn MediaGrid(mut props: MediaGridProps) -> Element {
let media_result = use_resource(use_reactive!(|(props)| async move {
let mut client = get_rpc_client();
let result = client.list_media(props.into_request()).await;
if let Ok(items) = result {
return Ok(items.into_inner());
let res = items.into_inner();
return Ok(res);
} else {
let err = result.err().unwrap();
let message = err.message();
@@ -48,7 +51,13 @@ pub fn MediaGrid(props: MediaGridProps) -> Element {
match media_result.cloned() {
Some(value) => match value {
Ok(result) => rsx! {
Ok(result) => {
let pagination = result.pagination.unwrap();
let total_pages = pagination.total_pages;
let total_items = pagination.total_items;
props.max_page.set(total_pages.max(1));
props.total_items.set(total_items.max(1));
return rsx! {
div {
class: "mediaGrid",
// oncontextmenu: oncontext,
@@ -58,7 +67,8 @@ pub fn MediaGrid(props: MediaGridProps) -> Element {
}
})},
}
},
};
}
Err(msg) => rsx! {
div {
class: "mediaGrid",

View File

@@ -5,6 +5,7 @@ mod media_item;
mod metrics_token;
mod navbar;
mod notif;
mod pagination;
mod search;
pub use context_menu::*;
pub use media_grid::*;
@@ -12,5 +13,6 @@ pub use media_item::*;
pub use metrics_token::*;
pub use navbar::*;
pub use notif::*;
pub use pagination::*;
pub use search::*;
mod icons;

View File

@@ -0,0 +1,30 @@
use dioxus::prelude::*;
#[component]
pub fn Pagination(page: Signal<i32>, max_page: Signal<i32>, item_count: Signal<i32>) -> Element {
let cur_page_val = page.cloned();
let max_page_val = max_page.cloned();
let item_count_val = item_count.cloned();
rsx! {
div {
class: "pagination",
a {
onclick: move|_| page.set(1),
"First"
}
a {
onclick: move|_| page.set((cur_page_val - 1).max(1)),
"Prev"
}
div { "Page {cur_page_val} of {max_page_val} ({item_count_val} Media Items)" }
a {
onclick: move|_| page.set((cur_page_val + 1).min(max_page_val)),
"Next"
}
a {
onclick: move|_| page.set(max_page_val),
"Last"
}
}
}
}

View File

@@ -1,14 +1,14 @@
use dioxus::prelude::*;
#[component]
pub fn Search(query: Signal<String>) -> Element {
pub fn Search(query: Signal<String>, page: Signal<i32>) -> Element {
rsx! {
div { class: "searchBar stickyTop",
div { class: "searchBar",
input {
r#type: "search",
placeholder: "Search Files",
value: query,
oninput: move |event| query.set(event.value()),
oninput: move |event| {query.set(event.value()); page.set(1);},
}
}
}

View File

@@ -1,4 +1,4 @@
use dioxus::signals::{Signal, Writable};
use dioxus::signals::{Signal, WritableExt};
use web_sys::window;
use crate::rpc::{login, logout};

View File

@@ -1,6 +1,6 @@
use crate::{
layouts::MainLayout,
views::{Home, Settings},
views::{Home, Media, Settings},
};
use dioxus::prelude::*;
@@ -8,8 +8,11 @@ use dioxus::prelude::*;
#[rustfmt::skip]
pub enum Route {
#[layout(MainLayout)]
#[route("/")]
Home { },
#[route("/media/:id")]
Media { id: String },
#[route("/settings")]
Settings {},
// #[end_layout]

View File

@@ -1,12 +1,18 @@
use crate::components::{MediaGrid, Search};
use crate::components::{MediaGrid, Pagination, Search};
use dioxus::prelude::*;
#[component]
pub fn Home() -> Element {
let query = use_signal(|| "".to_string());
let page = use_signal(|| 1 as i32);
let max_page = use_signal(|| 1 as i32);
let item_count = use_signal(|| 0 as i32);
rsx! {
Search { query }
MediaGrid { query: query.cloned() }
div {
class: "stickyTop",
Search { query, page },
Pagination { page, max_page, item_count },
}
MediaGrid { query: query.cloned(), page: page.cloned(), max_page, total_items: item_count }
}
}

View File

@@ -0,0 +1,8 @@
use dioxus::prelude::*;
#[component]
pub fn Media(id: String) -> Element {
rsx! {
{id}
}
}

View File

@@ -1,7 +1,9 @@
mod home;
mod login;
mod media;
pub use home::*;
pub use login::*;
pub use media::*;
mod settings;
pub use settings::Settings;

View File

@@ -5,13 +5,13 @@ using System.Text;
using System.Threading.Tasks;
namespace AobaCore.Models;
public class PagedResult<T>(List<T> items, int page, int pageSize, long totalItems)
public class PagedResult<T>(List<T> items, int page, int pageSize, int totalItems)
{
public List<T> Items { get; set; } = items;
public int Page { get; set; } = page;
public int PageSize { get; set; } = pageSize;
public long TotalItems { get; set; } = totalItems;
public long TotalPages { get; set; } = totalItems / pageSize;
public int TotalItems { get; set; } = totalItems;
public int TotalPages { get; set; } = (totalItems / pageSize) + 1;
public string? Query { get; set; }
}

View File

@@ -37,9 +37,14 @@ public class AobaService(IMongoDatabase db)
var total = await find.CountDocumentsAsync();
page -= 1;
var items = await find.Sort(sort).Skip(page * pageSize).Limit(pageSize).ToListAsync();
return new PagedResult<Media>(items, page, pageSize, total);
return new PagedResult<Media>(items, page, pageSize, (int)total);
}
public async Task<List<Media>> FindMediaWithExtAsync(string ext, CancellationToken cancellationToken = default)
{
var filter = Builders<Media>.Filter.Eq(m => m.Ext, ext);
return await _media.Find(filter).ToListAsync();
}
public Task AddMediaAsync(Media media, CancellationToken cancellationToken = default)
{
@@ -53,6 +58,13 @@ public class AobaService(IMongoDatabase db)
await _media.UpdateOneAsync(m => m.MediaId == mediaId, upate, cancellationToken: cancellationToken);
}
public async Task RemoveThumbnailAsync(ObjectId mediaId, ThumbnailSize size, CancellationToken cancellationToken = default)
{
var upate = Builders<Media>.Update.Unset(m => m.Thumbnails[size]);
await _media.UpdateOneAsync(m => m.MediaId == mediaId, upate, cancellationToken: cancellationToken);
}
public async Task<ObjectId> GetThumbnailIdAsync(ObjectId mediaId, ThumbnailSize size, CancellationToken cancellationToken = default)
{
var thumb = await _media.Find(m => m.MediaId == mediaId).Project(m => m.Thumbnails[size]).FirstOrDefaultAsync(cancellationToken);

View File

@@ -16,6 +16,7 @@ using SixLabors.ImageSharp.Processing;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net.Http.Headers;
using System.Text;
@@ -27,6 +28,28 @@ public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
{
private readonly GridFSBucket _gridfs = new GridFSBucket(db);
public async Task<Error?> DeleteThumbnailAsync(ObjectId mediaId, ThumbnailSize size)
{
var thumbId = await aobaService.GetThumbnailIdAsync(mediaId, size);
if (thumbId == default)
return null;
try
{
await _gridfs.DeleteAsync(thumbId);
await aobaService.RemoveThumbnailAsync(mediaId, size);
}
catch (GridFSFileNotFoundException)
{
//Ignore if the file was not found (somehow already deleted)
await aobaService.RemoveThumbnailAsync(mediaId, size);
}
catch (Exception e)
{
return new ExceptionError(e);
}
return null;
}
/// <summary>
///
/// </summary>
@@ -36,6 +59,7 @@ public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
/// <returns></returns>
public async Task<Maybe<Stream>> GetOrCreateThumbnailAsync(ObjectId mediaId, ThumbnailSize size, CancellationToken cancellationToken = default)
{
var existingThumb = await GetThumbnailAsync(mediaId, size, cancellationToken);
if (existingThumb != null)
return existingThumb;
@@ -132,7 +156,7 @@ public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
return result;
}
public Maybe<Stream> GenerateVideoThumbnail(Stream data, ThumbnailSize size, CancellationToken cancellationToken = default)
public static Maybe<Stream> GenerateVideoThumbnail(Stream data, ThumbnailSize size, CancellationToken cancellationToken = default)
{
var w = (int)size;
var fn = ObjectId.GenerateNewId().ToString();
@@ -166,6 +190,47 @@ public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
}
}
public static Maybe<Stream> GenerateAvifThumbnail(Stream data, ThumbnailSize size, CancellationToken cancellationToken)
{
var w = (int)size;
var fn = ObjectId.GenerateNewId().ToString();
var inFilePath = $"/tmp/{fn}.avif";
var outFilePath = $"/tmp/{fn}.webp";
using var source = new FileStream(inFilePath, FileMode.CreateNew);
data.CopyTo(source);
source.Flush();
source.Dispose();
data.Dispose();
try
{
var process = Process.Start(new ProcessStartInfo
{
FileName = "vips",
Arguments = $"smartcrop \"{inFilePath}\" \"{outFilePath}\"[Q=75] {w} {w}",
WorkingDirectory = "/tmp"
});
if (process == null)
return new Error("Failed to run vips command");
process.WaitForExit();
if (process.ExitCode != 0)
return new Error("Failed to convert");
var output = new MemoryStream();
using var oFile = File.OpenRead(outFilePath);
oFile.CopyTo(output);
output.Position = 0;
return output;
}
catch(Exception ex)
{
return ex;
}
finally
{
File.Delete(inFilePath);
File.Delete(outFilePath);
}
}
public async Task<Maybe<Stream>> GenerateDocumentThumbnailAsync(Stream data, ThumbnailSize size, CancellationToken cancellationToken = default)
{
return new NotImplementedException();

View File

@@ -14,7 +14,7 @@ namespace AobaServer.Controllers;
public class MediaController(AobaService aobaService, ILogger<MediaController> logger) : Controller
{
[HttpGet("{id}")]
[HttpGet("{id}/*")]
[HttpGet("{id}/{*fn}")]
[ResponseCache(Duration = int.MaxValue)]
public async Task<IActionResult> MediaAsync(ObjectId id, [FromServices] MongoClient client, CancellationToken cancellationToken)
{
@@ -29,6 +29,22 @@ public class MediaController(AobaService aobaService, ILogger<MediaController> l
return File(file, mime, true);
}
[HttpGet("{id}/dl")]
[HttpGet("{id}/dl/{*fn}")]
[ResponseCache(Duration = int.MaxValue)]
public async Task<IActionResult> DownloadMediaAsync(ObjectId id, [FromServices] MongoClient client, [FromQuery] string? fn = null, CancellationToken cancellationToken = default)
{
var file = await aobaService.GetFileStreamAsync(id, seekable: true, cancellationToken: cancellationToken);
if (file.HasError)
{
logger.LogError(file.Error.Exception, "Failed to load media stream");
return NotFound();
}
var mime = MimeTypesMap.GetMimeType(file.Value.FileInfo.Filename);
_ = aobaService.IncrementViewCountAsync(id, cancellationToken);
return File(file, mime, fn ?? file.Value.FileInfo.Filename, true);
}
/// <summary>
/// Redirect legacy media urls to the new url
/// </summary>

View File

@@ -22,17 +22,17 @@ RUN apt install -y protobuf-compiler libprotobuf-dev ffmpeg
# Install `dx`
RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash
RUN cargo binstall dioxus-cli --root /.cargo -y --force
RUN cargo binstall dioxus-cli@0.7.2 --root /.cargo -y --force
ENV PATH="/.cargo/bin:$PATH"
ARG VERSION
ENV APP_VERSION=$VERSION
# Create the final bundle folder. Bundle always executes in release mode with optimizations enabled
RUN dx bundle --platform web
RUN dx bundle --release --platform web
# Server Build
# This stage is used when running from VS in fast mode (Default for Debug configuration)
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
RUN apt-get update && apt-get install -y ffmpeg
RUN apt-get update && apt-get install -y ffmpeg #libvips libvips-tools
USER $APP_UID
WORKDIR /app
EXPOSE 8080

View File

@@ -121,10 +121,13 @@ builder.Services.AddAuthentication(options =>
builder.Services.AddAoba();
#if DEBUG
builder.Services.AddHostedService<DebugService>();
#endif
builder.Services.Configure<FormOptions>(opt =>
{
opt.ValueLengthLimit = int.MaxValue;
opt.MultipartBodyLengthLimit = int.MaxValue;
opt.MultipartBodyLengthLimit = long.MaxValue;
});
var app = builder.Build();

View File

@@ -54,8 +54,8 @@ message ListResponse {
message Pagination {
int32 page = 1;
int32 pageSize = 2;
int64 totalPages = 3;
int64 totalItems = 4;
int32 totalPages = 3;
int32 totalItems = 4;
optional string query = 5;
}

View File

@@ -0,0 +1,19 @@
using AobaCore.Services;
namespace AobaServer.Services;
public class DebugService(AobaService aobaService, ThumbnailService thumbnailService) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var mediaItems = await aobaService.FindMediaWithExtAsync(".avif", stoppingToken);
foreach (var item in mediaItems)
{
foreach (var size in item.Thumbnails.Keys)
{
await thumbnailService.DeleteThumbnailAsync(item.MediaId, size);
}
}
}
}