Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f2fe50eab | |||
| fbcb619544 | |||
| 0b2091b1e6 | |||
| 48f32e63c2 | |||
| fe13a5e3a7 | |||
| bb371e1b71 | |||
| 8721353b8d |
@@ -4,16 +4,19 @@ NAS_USER="your_username"
|
|||||||
NAS_PASS="your_password"
|
NAS_PASS="your_password"
|
||||||
NAS_FILE="/Team Folder/path/to/file.osheet"
|
NAS_FILE="/Team Folder/path/to/file.osheet"
|
||||||
|
|
||||||
#Loging
|
# Logging (optional, defaults to info)
|
||||||
RUST_LOG="info"
|
RUST_LOG="info"
|
||||||
|
|
||||||
|
# Web Server (optional, defaults to 3000)
|
||||||
|
WEB_PORT="3000"
|
||||||
|
|
||||||
# Nexrender
|
# Nexrender
|
||||||
NEXRENDER_API_URL="http://nexrender-server:3050/api/v1/jobs"
|
NEXRENDER_API_URL="http://nexrender-server:3050/api/v1/jobs"
|
||||||
OUTPUT_FOLDER="/path/to/output"
|
OUTPUT_FOLDER="/path/to/output"
|
||||||
|
|
||||||
# After Effects Templates
|
# After Effects Templates
|
||||||
TEMPLATE_DOUBLE_SRC="file:///path/to/double_team_template.aepx"
|
TEMPLATE_DOUBLE_SRC="file:///path/to/double_team_template.aep"
|
||||||
TEMPLATE_SINGLE_SRC="file:///path/to/single_team_template.aepx"
|
TEMPLATE_SINGLE_SRC="file:///path/to/single_team_template.aep"
|
||||||
TEMPLATE_COMPOSITION="main"
|
TEMPLATE_COMPOSITION="main"
|
||||||
TEMPLATE_OUTPUT_MODULE="h264"
|
TEMPLATE_OUTPUT_MODULE="h264"
|
||||||
TEMPLATE_OUTPUT_EXT="mp4"
|
TEMPLATE_OUTPUT_EXT="mp4"
|
||||||
229
Cargo.lock
generated
229
Cargo.lock
generated
@@ -10,12 +10,15 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ae_anons"
|
name = "ae_anons"
|
||||||
version = "0.1.1"
|
version = "0.2.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"askama",
|
||||||
|
"axum",
|
||||||
"bytes",
|
"bytes",
|
||||||
"calamine",
|
"calamine",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"clap",
|
||||||
"dotenv",
|
"dotenv",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -27,6 +30,8 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tower",
|
||||||
|
"tower-http",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -113,6 +118,58 @@ dependencies = [
|
|||||||
"derive_arbitrary",
|
"derive_arbitrary",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "askama"
|
||||||
|
version = "0.15.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b8246bcbf8eb97abef10c2d92166449680d41d55c0fc6978a91dec2e3619608"
|
||||||
|
dependencies = [
|
||||||
|
"askama_macros",
|
||||||
|
"itoa",
|
||||||
|
"percent-encoding",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "askama_derive"
|
||||||
|
version = "0.15.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2f9670bc84a28bb3da91821ef74226949ab63f1265aff7c751634f1dd0e6f97c"
|
||||||
|
dependencies = [
|
||||||
|
"askama_parser",
|
||||||
|
"basic-toml",
|
||||||
|
"memchr",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustc-hash",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "askama_macros"
|
||||||
|
version = "0.15.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0756b45480437dded0565dfc568af62ccce146fb6cfe902e808ba86e445f44f"
|
||||||
|
dependencies = [
|
||||||
|
"askama_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "askama_parser"
|
||||||
|
version = "0.15.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d0af3691ba3af77949c0b5a3925444b85cb58a0184cc7fec16c68ba2e7be868"
|
||||||
|
dependencies = [
|
||||||
|
"rustc-hash",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"unicode-ident",
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atomic-waker"
|
name = "atomic-waker"
|
||||||
version = "1.1.2"
|
version = "1.1.2"
|
||||||
@@ -125,12 +182,73 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
||||||
|
dependencies = [
|
||||||
|
"axum-core",
|
||||||
|
"bytes",
|
||||||
|
"form_urlencoded",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"itoa",
|
||||||
|
"matchit",
|
||||||
|
"memchr",
|
||||||
|
"mime",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"serde_core",
|
||||||
|
"serde_json",
|
||||||
|
"serde_path_to_error",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tower",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum-core"
|
||||||
|
version = "0.5.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"mime",
|
||||||
|
"pin-project-lite",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "basic-toml"
|
||||||
|
version = "0.1.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.11.0"
|
version = "2.11.0"
|
||||||
@@ -199,6 +317,46 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap"
|
||||||
|
version = "4.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
|
||||||
|
dependencies = [
|
||||||
|
"clap_builder",
|
||||||
|
"clap_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_builder"
|
||||||
|
version = "4.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
|
||||||
|
dependencies = [
|
||||||
|
"anstream",
|
||||||
|
"anstyle",
|
||||||
|
"clap_lex",
|
||||||
|
"strsim",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_derive"
|
||||||
|
version = "4.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_lex"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "codepage"
|
name = "codepage"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -519,6 +677,12 @@ version = "0.17.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
|
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heck"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.4.0"
|
version = "1.4.0"
|
||||||
@@ -552,12 +716,24 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-range-header"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httparse"
|
name = "httparse"
|
||||||
version = "1.10.1"
|
version = "1.10.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpdate"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.9.0"
|
version = "1.9.0"
|
||||||
@@ -572,6 +748,7 @@ dependencies = [
|
|||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
|
"httpdate",
|
||||||
"itoa",
|
"itoa",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
@@ -869,6 +1046,12 @@ version = "0.4.29"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matchit"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.0"
|
version = "2.8.0"
|
||||||
@@ -1199,6 +1382,12 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc-hash"
|
||||||
|
version = "2.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -1338,6 +1527,17 @@ dependencies = [
|
|||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_path_to_error"
|
||||||
|
version = "0.1.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_urlencoded"
|
name = "serde_urlencoded"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
@@ -1400,6 +1600,12 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strsim"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "subtle"
|
name = "subtle"
|
||||||
version = "2.6.1"
|
version = "2.6.1"
|
||||||
@@ -1575,6 +1781,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1585,14 +1792,24 @@ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"http-range-header",
|
||||||
|
"httpdate",
|
||||||
"iri-string",
|
"iri-string",
|
||||||
|
"mime",
|
||||||
|
"mime_guess",
|
||||||
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1613,6 +1830,7 @@ version = "0.1.44"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"log",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
]
|
]
|
||||||
@@ -1940,6 +2158,15 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.51.0"
|
version = "0.51.0"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ae_anons"
|
name = "ae_anons"
|
||||||
version = "0.1.1"
|
version = "0.2.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
license-file = "LICENSE"
|
license-file = "LICENSE"
|
||||||
@@ -28,6 +28,13 @@ log = "0.4"
|
|||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
bytes = "1.9"
|
bytes = "1.9"
|
||||||
|
|
||||||
|
# Web server (updated to latest stable versions)
|
||||||
|
axum = "0.8.9"
|
||||||
|
tower = "0.5"
|
||||||
|
tower-http = { version = "0.6", features = ["fs", "trace"] }
|
||||||
|
askama = "0.15"
|
||||||
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1.0", features = ["full", "rt-multi-thread"] }
|
tokio = { version = "1.0", features = ["full", "rt-multi-thread"] }
|
||||||
|
|
||||||
|
|||||||
410
README.md
410
README.md
@@ -6,38 +6,56 @@
|
|||||||
</p>
|
</p>
|
||||||
<!-- markdownlint-enable MD033 -->
|
<!-- markdownlint-enable MD033 -->
|
||||||
|
|
||||||
[](https://opensource.org/licenses/MIT) [](https://www.rust-lang.org/) [](https://git.tvstart.ru/lexx/AE_Anons)
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
[](https://www.rust-lang.org/)
|
||||||
|
[](https://git.tvstart.ru/lexx/AE_Anons)
|
||||||
|
|
||||||
Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office.
|
Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office.
|
||||||
|
|
||||||
|
## Новое в версии 0.2.2
|
||||||
|
|
||||||
|
- **Веб-интерфейс**: Удобное управление заданиями через браузер
|
||||||
|
- **Мониторинг в реальном времени**: Отслеживание статуса всех заданий
|
||||||
|
- **Управление очередью**: Остановка, очистка и генерация заданий из веб-интерфейса
|
||||||
|
- **Тёмная/светлая тема**: Автоматическое определение системной темы
|
||||||
|
- **Сортировка и фильтрация**: Удобная работа с большим количеством заданий
|
||||||
|
|
||||||
## Обзор
|
## Обзор
|
||||||
|
|
||||||
AE Anons — это CLI-утилита на Rust, которая выступает как **интеллектуальный генератор заданий**
|
AE Anons — это CLI-утилита и веб-сервер на Rust, которая выступает как **интеллектуальный генератор заданий**
|
||||||
для [Nexrender](https://github.com/inlife/nexrender) — опенсорсного оркестратора рендеринга
|
для [Nexrender](https://github.com/inlife/nexrender) — опенсорсного оркестратора рендеринга
|
||||||
After Effects (лицензия MIT).
|
After Effects (лицензия MIT).
|
||||||
|
|
||||||
1. Подключения к NAS Synology для получения данных расписания из файлов офисных таблиц (.osheet)
|
### Режимы работы
|
||||||
2. Парсинга Excel данных, содержащих информацию о спортивных событиях, командах, каналах и временных интервалах
|
|
||||||
3. Генерации заданий Nexrender с соответствующими шаблонами и ресурсами
|
1. **Однократная обработка** (`--once` или без флагов):
|
||||||
4. Мониторинга завершения рендеринга и управления выходными файлами
|
- Подключение к NAS Synology
|
||||||
|
- Парсинг Excel данных
|
||||||
|
- Генерация и отправка заданий Nexrender
|
||||||
|
- Мониторинг завершения и выход
|
||||||
|
|
||||||
|
2. **Веб-сервер** (`--web`):
|
||||||
|
- Запуск веб-интерфейса на порту `:3000`
|
||||||
|
- Управление заданиями через браузер
|
||||||
|
- Автообновление статуса каждую минуту
|
||||||
|
|
||||||
## Особенности
|
## Особенности
|
||||||
|
|
||||||
- **Интеграция с Synology**: Аутентификация и загрузка файлов с NAS Synology
|
- **Интеграция с Synology**: Аутентификация и загрузка файлов с NAS Synology
|
||||||
- **Экспорт офисных таблиц**: Автоматическое преобразование файлов .osheet в формат Excel
|
- **Экспорт офисных таблиц**: Автоматическое преобразование файлов .osheet в формат Excel
|
||||||
- **Гибкий парсинг данных**: Динамический парсинг листов с обнаружением заголовков
|
- **Гибкий парсинг данных**: Динамический парсинг листов с обнаружением заголовков
|
||||||
- **Множественная генерация вариантов**: Создание "Сегодня", "Завтра" и датированных версий для каждого анонса
|
- **Множественная генерация вариантов**: Создание "Сегодня", "Завтра" и датированных версий
|
||||||
- **Умное управление логотипами**: Автоматическое разрешение и масштабирование логотипов на основе хэштегов `#` в имени команды
|
- **Умное управление логотипами**: Автоматическое разрешение и масштабирование логотипов
|
||||||
- **Оркестрация заданий Nexrender**: Автоматическая отправка, мониторинг и очистка заданий
|
- **Веб-интерфейс**: Удобное управление и мониторинг заданий
|
||||||
- **Профессиональное логирование**: Структурированный журнал с возможностью настройки уровня детализации
|
- **REST API**: Программный доступ к управлению заданиями
|
||||||
- **Конфигурация через переменные окружения**: Все параметры управляются через файл `.env`
|
- **Профессиональное логирование**: Структурированный журнал с настройкой уровня
|
||||||
|
|
||||||
## Предварительные требования
|
## Предварительные требования
|
||||||
|
|
||||||
- Rust 1.70 или выше
|
- Rust 1.70 или выше
|
||||||
- Доступ к NAS Synology с установленными и включенными пакетами File Station и Office
|
- Доступ к NAS Synology с пакетами File Station и Office
|
||||||
- Экземпляр _server_ и _worker_(не менее одного) Nexrender
|
- Экземпляр _server_ и _worker_(не менее одного) Nexrender
|
||||||
- Шаблоны After Effects, настроенные на _worker_ (формат Adobe After Effects 2024 .aepx)
|
- Шаблоны After Effects (формат Adobe After Effects 2024 .aepx)
|
||||||
|
|
||||||
## Установка
|
## Установка
|
||||||
|
|
||||||
@@ -54,8 +72,6 @@ cd ae_anons
|
|||||||
cargo build --release
|
cargo build --release
|
||||||
```
|
```
|
||||||
|
|
||||||
Бинарный файл будет доступен по пути `target/release/ae_anons`
|
|
||||||
|
|
||||||
### 3. Настройка окружения
|
### 3. Настройка окружения
|
||||||
|
|
||||||
Скопируйте пример конфигурации и заполните своими данными:
|
Скопируйте пример конфигурации и заполните своими данными:
|
||||||
@@ -66,35 +82,47 @@ cp .env.example .env
|
|||||||
|
|
||||||
Затем отредактируйте файл .env:
|
Затем отредактируйте файл .env:
|
||||||
|
|
||||||
```env
|
|
||||||
# Synology NAS
|
|
||||||
NAS_FQDN=https://your-nas.domain.com
|
|
||||||
NAS_USER=your_username
|
|
||||||
NAS_PASS=your_password
|
|
||||||
NAS_FILE=/team-folders/path/to/your/file.osheet
|
|
||||||
|
|
||||||
# Nexrender
|
|
||||||
NEXRENDER_API_URL=http://your-nexrender-server:3000/api/v1/jobs
|
|
||||||
OUTPUT_FOLDER=//your-storage/path/to/output
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
RUST_LOG=info
|
|
||||||
```
|
|
||||||
|
|
||||||
См. раздел Конфигурация для подробной информации.
|
См. раздел Конфигурация для подробной информации.
|
||||||
|
|
||||||
## Использование
|
## Использование
|
||||||
|
|
||||||
### Базовое выполнение
|
### Однократная обработка
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Запуск с настройками по умолчанию
|
# Запуск с настройками по умолчанию
|
||||||
./target/release/ae_anons
|
./target/release/ae_anons
|
||||||
|
|
||||||
# Запуск с пользовательским уровнем логирования
|
# Явно указать однократный режим
|
||||||
|
./target/release/ae_anons --once
|
||||||
|
|
||||||
|
# С отладочным логированием
|
||||||
RUST_LOG=debug ./target/release/ae_anons
|
RUST_LOG=debug ./target/release/ae_anons
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Веб-сервер
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Запуск веб-интерфейса на порту по умолчанию (3000)
|
||||||
|
./target/release/ae_anons --web
|
||||||
|
|
||||||
|
# Или с указанием другого порта через .env файл
|
||||||
|
# WEB_PORT=8080 ./target/release/ae_anons --web
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Веб-интерфейс
|
||||||
|
|
||||||
|
После запуска веб-сервера откройте браузер:
|
||||||
|
|
||||||
|
- Главная страница: <http://localhost:3000>
|
||||||
|
|
||||||
|
- API эндпоинты:
|
||||||
|
- `GET /api/jobs` - список всех заданий
|
||||||
|
- `POST /api/generate` - запуск генерации
|
||||||
|
- `POST /api/cleanup` - очистка завершённых
|
||||||
|
- `POST /api/jobs/stop-all` - остановка активных
|
||||||
|
- `GET /api/status` - статус сервера
|
||||||
|
|
||||||
## Уровни логирования
|
## Уровни логирования
|
||||||
|
|
||||||
Управляйте детализацией вывода через переменную окружения `RUST_LOG`:
|
Управляйте детализацией вывода через переменную окружения `RUST_LOG`:
|
||||||
@@ -121,12 +149,13 @@ RUST_LOG=debug cargo run
|
|||||||
| `NAS_FILE` | Да | Полный путь к файлу .osheet на NAS |
|
| `NAS_FILE` | Да | Полный путь к файлу .osheet на NAS |
|
||||||
| `NEXRENDER_API_URL` | Да | Конечная точка API сервера Nexrender |
|
| `NEXRENDER_API_URL` | Да | Конечная точка API сервера Nexrender |
|
||||||
| `OUTPUT_FOLDER` | Да | Сетевой путь для рендеренных видео |
|
| `OUTPUT_FOLDER` | Да | Сетевой путь для рендеренных видео |
|
||||||
| `RUST_LOG` | Нет | Уровень детализации логирования (по умолчанию: info) |
|
|
||||||
| `TEMPLATE_DOUBLE_SRC` | Да | Путь к AEP-шаблону для двух команд |
|
| `TEMPLATE_DOUBLE_SRC` | Да | Путь к AEP-шаблону для двух команд |
|
||||||
| `TEMPLATE_SINGLE_SRC` | Да | Путь к AEP-шаблону для одной команды |
|
| `TEMPLATE_SINGLE_SRC` | Да | Путь к AEP-шаблону для одной команды |
|
||||||
| `TEMPLATE_COMPOSITION` | Да | Имя композиции в проекте AE (например, `main`) |
|
| `TEMPLATE_COMPOSITION` | Да | Имя композиции в проекте AE (например, `main`) |
|
||||||
| `TEMPLATE_OUTPUT_MODULE`| Да | Имя модуля вывода в AE (например, `h264`) |
|
| `TEMPLATE_OUTPUT_MODULE`| Да | Имя модуля вывода в AE (например, `h264`) |
|
||||||
| `TEMPLATE_OUTPUT_EXT` | Да | Расширение выходного файла (например, `mp4`) |
|
| `TEMPLATE_OUTPUT_EXT` | Да | Расширение выходного файла (например, `mp4`) |
|
||||||
|
| `WEB_PORT` | Нет | Порт для веб-сервера (по умолчанию: 3000) |
|
||||||
|
| `RUST_LOG` | Нет | Уровень детализации логирования (по умолчанию: info) |
|
||||||
|
|
||||||
### Пример файла `.env`
|
### Пример файла `.env`
|
||||||
|
|
||||||
@@ -137,9 +166,12 @@ NAS_USER="your_username"
|
|||||||
NAS_PASS="your_password"
|
NAS_PASS="your_password"
|
||||||
NAS_FILE="/Team Folder/path/to/file.osheet"
|
NAS_FILE="/Team Folder/path/to/file.osheet"
|
||||||
|
|
||||||
#Loging
|
# Logging
|
||||||
RUST_LOG="info"
|
RUST_LOG="info"
|
||||||
|
|
||||||
|
# Web Server
|
||||||
|
WEB_PORT="3000"
|
||||||
|
|
||||||
# Nexrender
|
# Nexrender
|
||||||
NEXRENDER_API_URL="http://nexrender-server:3050/api/v1/jobs"
|
NEXRENDER_API_URL="http://nexrender-server:3050/api/v1/jobs"
|
||||||
OUTPUT_FOLDER="/path/to/output"
|
OUTPUT_FOLDER="/path/to/output"
|
||||||
@@ -183,7 +215,7 @@ TEMPLATE_OUTPUT_EXT="mp4"
|
|||||||
|
|
||||||
Сопоставляет названия каналов с их логотипами. Поля заполняются вручную без выпадающих списков.
|
Сопоставляет названия каналов с их логотипами. Поля заполняются вручную без выпадающих списков.
|
||||||
|
|
||||||
| CHANEL | LINK |
|
| CHANELL | LINK |
|
||||||
|---------|-----------------------------------------|
|
|---------|-----------------------------------------|
|
||||||
| КАНАЛ | \\server\share\path\to\channel_logo.png |
|
| КАНАЛ | \\server\share\path\to\channel_logo.png |
|
||||||
| TRIUMPH | \\server\share\path\to\triumph_logo.png |
|
| TRIUMPH | \\server\share\path\to\triumph_logo.png |
|
||||||
@@ -192,14 +224,17 @@ TEMPLATE_OUTPUT_EXT="mp4"
|
|||||||
|
|
||||||
Основной источник данных для генерации анонсов. Большинство полей заполняются из выпадающих списков, основанных на других листах.
|
Основной источник данных для генерации анонсов. Большинство полей заполняются из выпадающих списков, основанных на других листах.
|
||||||
|
|
||||||
Обязательные колонки: DATA, TIME, CHANEL, SPORT, LEAGUE, TEAM A, TEAM B
|
Обязательные колонки: DATA, TIME, CHANELL, SPORT, LEAGUE, TEAM A, TEAM B
|
||||||
|
|
||||||
**Примечание**: Колонка LEAGUE обязательна для заполнения, но не имеет выпадающего списка. Проверка орфографии и опечаток отсутствует!
|
**Примечания:**
|
||||||
|
|
||||||
| STATE | TRIPPLE | DATA | TIME | SPORT | LEAGUE | CHANEL | TEAM A | TEAM B |
|
- Колонка LEAGUE обязательна для заполнения, но не имеет выпадающего списка. Проверка орфографии и опечаток отсутствует!
|
||||||
|--------|---------|-----------|-------|----------|---------------------------|---------|-----------------|-------------------|
|
- Колонка DATA должна содержать дату в формате `ДД.ММ.ГГГГ` (например, `28.2.2026`).
|
||||||
| TRUE | TRUE | 27.2.2026 | 23:40 | Футбол | Чемпионат Португалии | START | Спортинг#ll#550 | Эшторил#ll#550 |
|
|
||||||
| FALSE | TRUE | 28.2.2026 | 12:55 | Волейбол | Чемпионат Турции. Женщины | TRIUMPH | Бешикташ | Галатасарай#Turki |
|
| STATE | TRIPPLE | DATA | TIME | SPORT | LEAGUE | CHANELL | TEAM A | TEAM B |
|
||||||
|
|-------|---------|-----------|-------|---------|---------------------------|---------|-----------------|------------------|
|
||||||
|
| TRUE | TRUE | 27.2.2026 | 23:40 | Футбол | Чемпионат Португалии | START | Спортинг#ll#550 | Эшторил#ll#550 |
|
||||||
|
| FALSE | TRUE | 28.2.2026 | 12:55 | Волейбол| Чемпионат Турции. Женщины | TRIUMPH | Бешикташ | Галатасарай#Turki|
|
||||||
|
|
||||||
### Формат имен команд
|
### Формат имен команд
|
||||||
|
|
||||||
@@ -233,7 +268,8 @@ TEMPLATE_OUTPUT_EXT="mp4"
|
|||||||
YYYYMMDD_Sport_League_TeamA_TeamB_Channel[_Variant].mp4
|
YYYYMMDD_Sport_League_TeamA_TeamB_Channel[_Variant].mp4
|
||||||
```
|
```
|
||||||
|
|
||||||
Имена файлов транслитерируются в латиницу. Примеры:
|
Имена файлов транслитерируются в латиницу.
|
||||||
|
**Примеры:**
|
||||||
|
|
||||||
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH.mp4`
|
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH.mp4`
|
||||||
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH_Today.mp4`
|
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH_Today.mp4`
|
||||||
@@ -253,35 +289,174 @@ YYYYMMDD_Sport_League_TeamA_TeamB_Channel[_Variant].mp4
|
|||||||
|
|
||||||
## Требования к шаблонам After Effects
|
## Требования к шаблонам After Effects
|
||||||
|
|
||||||
Шаблоны должны быть предварительно настроены на узлах рендеринга со следующими именами слоев:
|
Шаблоны должны быть предварительно настроены на узлах рендеринга со следующими именами слоёв.
|
||||||
|
|
||||||
### Типы шаблонов
|
### Файлы шаблонов
|
||||||
|
|
||||||
| Файл шаблона | Назначение |
|
Настраиваются через переменные окружения:
|
||||||
|------------------------|---------------------------------|
|
|
||||||
| `PackShot_DOUBLE.aepx` | Сопоставление с двумя командами |
|
|
||||||
| `PackShot_SINGLE.aepx` | Анонсы для одной команды |
|
|
||||||
|
|
||||||
### Обязательные слои
|
| Переменная | Назначение | Пример значения |
|
||||||
|
|-------------------------|-------------------------------------|------------------------------------|
|
||||||
|
| `TEMPLATE_DOUBLE_SRC` | Шаблон для матчей с двумя командами | `file:///path/to/double.aepx` |
|
||||||
|
| `TEMPLATE_SINGLE_SRC` | Шаблон для анонсов с одной командой | `file:///path/to/single.aepx` |
|
||||||
|
| `TEMPLATE_COMPOSITION` | Имя главной композиции | `main` |
|
||||||
|
| `TEMPLATE_OUTPUT_MODULE`| Имя модуля вывода | `h264` |
|
||||||
|
| `TEMPLATE_OUTPUT_EXT` | Расширение выходного файла | `mp4` |
|
||||||
|
|
||||||
| Имя слоя | Тип | Описание |
|
### Обязательные слои в шаблонах
|
||||||
|------------------------|-------|-------------------------------------------------|
|
|
||||||
| DATA | Text | Отображение даты (автоматически подстраивается) |
|
#### Текстовые слои
|
||||||
| TIME / TIME_H / TIME_M | Text | Отображение времени |
|
|
||||||
| LEAGUE | Text | Название лиги |
|
| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание |
|
||||||
| SPORT | Text | Категория спорта |
|
|-----------|:-------------:|:-------------:|-------------------------------------------------|
|
||||||
| TEAMS | Text | Скомбинированные имена команд |
|
| `DATA` | ✅ | ✅ | Отображение даты (автоматически подстраивается) |
|
||||||
| TEAM_A_LOGO | Image | Логотип домашней команды |
|
| `TIME_H` | ✅ | ❌ | Часы (отдельный слой) |
|
||||||
| TEAM_B_LOGO | Image | Логотип гостевой команды |
|
| `TIME_M` | ✅ | ❌ | Минуты (отдельный слой) |
|
||||||
| CHANEL | Image | Логотип канала |
|
| `TIME` | ❌ | ✅ | Полное время (единый слой) |
|
||||||
| TOP | Video | Наложение видео оформления |
|
| `LEAGUE` | ✅ | ✅ | Название лиги/турнира |
|
||||||
|
| `SPORT` | ✅ | ✅ | Категория спорта |
|
||||||
|
| `TEAMS` | ✅ | ✅ | Скомбинированные имена команд |
|
||||||
|
|
||||||
|
#### Слои с изображениями
|
||||||
|
|
||||||
|
| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание |
|
||||||
|
|----------------|:-------------:|:-------------:|-----------------------------|
|
||||||
|
| `TEAM_A_LOGO` | ✅ | ✅ | Логотип команды A |
|
||||||
|
| `TEAM_B_LOGO` | ✅ | ❌ | Логотип команды B |
|
||||||
|
| `CHANELL` | ✅ | ✅ | Логотип канала (две буквы L)|
|
||||||
|
|
||||||
|
#### Видео слои
|
||||||
|
|
||||||
|
| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание |
|
||||||
|
|-----------|:-------------:|:-------------:|----------------------------|
|
||||||
|
| `TOP` | ✅ | ✅ | Наложение видео оформления |
|
||||||
|
|
||||||
|
### Особенности шаблонов
|
||||||
|
|
||||||
|
#### Шаблон DOUBLE (две команды)
|
||||||
|
|
||||||
|
Используется когда в строке Excel заполнены оба поля `TEAM A` и `TEAM B`.
|
||||||
|
|
||||||
|
Обязательные слои:
|
||||||
|
|
||||||
|
- Все текстовые слои с `TIME_H` и `TIME_M` вместо `TIME`
|
||||||
|
- Оба логотипа команд: `TEAM_A_LOGO` и `TEAM_B_LOGO`
|
||||||
|
|
||||||
|
#### Шаблон SINGLE (одна команда)
|
||||||
|
|
||||||
|
Используется когда заполнено только одно поле команды.
|
||||||
|
|
||||||
|
Обязательные слои:
|
||||||
|
|
||||||
|
- Текстовый слой `TIME` вместо `TIME_H` и `TIME_M`
|
||||||
|
- Только логотип `TEAM_A_LOGO`
|
||||||
|
|
||||||
|
### Автоматические корректировки
|
||||||
|
|
||||||
|
AE Anons автоматически применяет следующие настройки к слоям:
|
||||||
|
|
||||||
|
#### Слой DATA
|
||||||
|
|
||||||
|
В зависимости от отображаемого текста и типа шаблона:
|
||||||
|
|
||||||
|
| Текст | Шаблон | Font Size | Anchor Point |
|
||||||
|
|------------|----------|-----------|--------------|
|
||||||
|
| "сегодня" | DOUBLE | 105 | [0, 5] |
|
||||||
|
| "завтра" | DOUBLE | 115 | [0, 25] |
|
||||||
|
| дата (<6) | DOUBLE | 120 | [0, 20] |
|
||||||
|
|
||||||
|
#### Слои TIME_H, TIME_M, TIME
|
||||||
|
|
||||||
|
Корректировка Anchor Point в зависимости от длины текста:
|
||||||
|
|
||||||
|
| Условие | Anchor Point |
|
||||||
|
|-------------------|--------------|
|
||||||
|
| 1 символ | [60, 0] |
|
||||||
|
| 2 символа, <20 | [20, 0] |
|
||||||
|
|
||||||
|
#### Слой LEAGUE
|
||||||
|
|
||||||
|
Если длина текста превышает 16 символов, размер шрифта уменьшается до 73.
|
||||||
|
|
||||||
|
#### Слой TEAMS
|
||||||
|
|
||||||
|
Если суммарная длина имён команд ≥ 32 символов, размер шрифта уменьшается до 55.
|
||||||
|
|
||||||
|
#### Логотипы (TEAM_A_LOGO, TEAM_B_LOGO)
|
||||||
|
|
||||||
|
Если в имени команды указан целевой размер через `#` (например, `Спортинг##550`), применяется выражение масштабирования:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
if (width > height) {
|
||||||
|
max_size = width;
|
||||||
|
} else {
|
||||||
|
max_size = height;
|
||||||
|
}
|
||||||
|
var real_size = 550 / max_size * 100;
|
||||||
|
[real_size, real_size]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Пример структуры слоёв в After Effects
|
||||||
|
|
||||||
|
Ниже представлен рекомендуемый порядок слоёв в композиции. Порядок важен для правильного наложения элементов.
|
||||||
|
|
||||||
|
```text
|
||||||
|
📁 main (композиция)
|
||||||
|
├── 🎬 TOP (видео слой) # Видео-оверлей (обязательный)
|
||||||
|
├── 📝 TEAMS (текстовый слой) # Имена команд (обязательный)
|
||||||
|
├── 📝 LEAGUE (текстовый слой) # Название лиги (обязательный)
|
||||||
|
├── 📝 SPORT (текстовый слой) # Вид спорта (обязательный)
|
||||||
|
├── 📝 DATA (текстовый слой) # Дата (обязательный)
|
||||||
|
├── 📝 TIME (текстовый слой) # Время (только SINGLE)
|
||||||
|
├── 📝 TIME_H (текстовый слой) # Часы (только DOUBLE)
|
||||||
|
├── 📝 TIME_M (текстовый слой) # Минуты (только DOUBLE)
|
||||||
|
├── 🖼️ CHANELL (слой изображения) # Логотип канала (обязательный)
|
||||||
|
├── 🖼️ TEAM_A_LOGO (слой изображения) # Логотип команды A (обязательный)
|
||||||
|
├── 🖼️ TEAM_B_LOGO (слой изображения) # Логотип команды B (только DOUBLE)
|
||||||
|
└── 🖼️ BOTTOM (слой изображения) # Фоновый слой (опционально)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Формат проекта и совместимость с After Effects
|
||||||
|
|
||||||
|
**Поддерживаемые форматы проектов:**
|
||||||
|
|
||||||
|
- `.aep` — стандартный бинарный формат After Effects (рекомендуется)
|
||||||
|
- `.aepx` — XML-формат проекта (поддерживается с AE CC 2015)
|
||||||
|
|
||||||
|
**Совместимость версий After Effects:**
|
||||||
|
|
||||||
|
| Версия After Effects | Поддерживаемые форматы | Особенности |
|
||||||
|
|----------------------|------------------------|-------------------------------------------------|
|
||||||
|
| CS 5.5 | `.aep` | Базовая поддержка |
|
||||||
|
| CC / CC 2014 | `.aep` | Полная поддержка |
|
||||||
|
| CC 2015 - CC 2019 | `.aep`, `.aepx` | Добавлена поддержка XML формата `.aepx` |
|
||||||
|
| CC 2020 - CC 2022 | `.aep`, `.aepx` | Рекомендуется использовать `.aep` |
|
||||||
|
| CC 2023 и новее | `.aep`, `.aepx` | **Требуется настройка Output Module** (см. ниже)|
|
||||||
|
|
||||||
|
> **⚠️ Важно для After Effects 2023+:**
|
||||||
|
>
|
||||||
|
> В версиях After Effects 2023 и новее критически важно настроить **Output Module** в шаблоне проекта. Бинарный файл рендеринга (`aerender`) не будет обрабатывать композицию без явно указанного модуля вывода, даже если в проекте используется модуль по умолчанию.
|
||||||
|
>
|
||||||
|
> AE Anons автоматически решает эту проблему, используя параметры `TEMPLATE_OUTPUT_MODULE` и `TEMPLATE_OUTPUT_EXT` из `.env` файла. Убедитесь, что эти значения соответствуют настройкам вашего шаблона.
|
||||||
|
|
||||||
### Настройки композиции
|
### Настройки композиции
|
||||||
|
|
||||||
- Имя композиции: `pack`
|
| Параметр | Переменная окружения | Пример значения | Описание |
|
||||||
- Выходной модуль: `Start_h264`
|
|---------------------------|--------------------------|-----------------|---------------------------------------------|
|
||||||
- Формат вывода: `mp4`
|
| Имя композиции | `TEMPLATE_COMPOSITION` | `main` | Имя главной композиции в проекте AE |
|
||||||
- Формат проекта: Adobe After Effects 2024 .aepx
|
| Выходной модуль | `TEMPLATE_OUTPUT_MODULE` | `h264` | Имя модуля вывода в AE |
|
||||||
|
| Расширение выходного файла| `TEMPLATE_OUTPUT_EXT` | `mp4` | Расширение выходного файла |
|
||||||
|
|
||||||
|
**Рекомендации по выбору формата:**
|
||||||
|
|
||||||
|
1. Используйте `.aep` для максимальной совместимости со всеми версиями AE
|
||||||
|
2. Версия After Effects на worker-машине должна быть не ниже версии, в которой создан проект
|
||||||
|
3. Для AE 2023+ убедитесь, что в шаблоне настроен Output Module с именем, указанным в `TEMPLATE_OUTPUT_MODULE`
|
||||||
|
|
||||||
|
**Источники:**
|
||||||
|
|
||||||
|
- [Nexrender - Tested with After Effects versions](https://github.com/inlife/nexrender#tested-with)
|
||||||
|
- [Adobe Aerender documentation](https://helpx.adobe.com/after-effects/using/automated-rendering-network-rendering.html)
|
||||||
|
|
||||||
## Устранение неисправностей
|
## Устранение неисправностей
|
||||||
|
|
||||||
@@ -325,7 +500,6 @@ RUST_LOG=debug ./target/release/ae_anons
|
|||||||
- **Большие таблицы**: Ограничение обработки до 10,000 строк на лист
|
- **Большие таблицы**: Ограничение обработки до 10,000 строк на лист
|
||||||
- **Задержка сети**: Загрузка файлов с NAS может занимать время для больших файлов
|
- **Задержка сети**: Загрузка файлов с NAS может занимать время для больших файлов
|
||||||
- **Параллельные задания**: Nexrender управляет очередью заданий внутренне
|
- **Параллельные задания**: Nexrender управляет очередью заданий внутренне
|
||||||
- **Использование памяти**: Парсинг Excel сохраняет всю книгу в памяти
|
|
||||||
|
|
||||||
## Примечания по безопасности
|
## Примечания по безопасности
|
||||||
|
|
||||||
@@ -340,39 +514,60 @@ RUST_LOG=debug ./target/release/ae_anons
|
|||||||
|
|
||||||
```shell
|
```shell
|
||||||
ae_anons/
|
ae_anons/
|
||||||
|
├── .gitignore
|
||||||
|
├── .env.example
|
||||||
├── Cargo.toml
|
├── Cargo.toml
|
||||||
├── LICENSE
|
├── LICENSE
|
||||||
|
├── README.md
|
||||||
├── assets/
|
├── assets/
|
||||||
│ └── logo.png
|
│ └── logo.png
|
||||||
├── README.md
|
|
||||||
├── .env.example
|
|
||||||
└── src/
|
└── src/
|
||||||
├── main.rs # Точка входа и оркестрация приложения
|
├── main.rs # Точка входа, CLI
|
||||||
├── config.rs # Управление конфигурацией
|
├── config.rs # Конфигурация из .env
|
||||||
├── nexrender.rs # Генерация заданий Nexrender и структура данных
|
├── nexrender.rs # Модели заданий Nexrender
|
||||||
└── synology.rs # Клиент API Synology
|
├── synology.rs # Клиент Synology API
|
||||||
|
├── processor.rs # Логика обработки
|
||||||
|
├── web.rs # Веб-сервер и API
|
||||||
|
└── static/
|
||||||
|
├── index.html # Веб-интерфейс
|
||||||
|
└── style.css # Стили
|
||||||
```
|
```
|
||||||
|
|
||||||
## Зависимости
|
## Зависимости
|
||||||
|
|
||||||
|
### Основные зависимости
|
||||||
|
|
||||||
|
| Crate | Версия | Назначение |
|
||||||
|
|--------------------|---------|-----------------------------------------|
|
||||||
|
| reqwest | 0.12 | HTTP клиент для коммуникации с API |
|
||||||
|
| serde / serde_json | 1.0 | Сериализация и десериализация JSON |
|
||||||
|
| calamine | 0.26 | Парсинг файлов Excel (.xlsx, .xls) |
|
||||||
|
| chrono | 0.4 | Обработка дат и времени |
|
||||||
|
| tokio | 1.0 | Асинхронная среда выполнения |
|
||||||
|
| dotenv | 0.15 | Загрузка конфигурации из .env файла |
|
||||||
|
| log / env_logger | 0.4/0.11| Система логирования с уровнями |
|
||||||
|
| thiserror | 2.0 | Эргономичные определения типов ошибок |
|
||||||
|
| anyhow | 1.0 | Упрощённая обработка ошибок |
|
||||||
|
| regex | 1.11 | Регулярные выражения |
|
||||||
|
| urlencoding | 2.1 | Кодирование URL для API запросов |
|
||||||
|
| bytes | 1.9 | Работа с байтовыми данными |
|
||||||
|
| futures | 0.3 | Асинхронные примитивы |
|
||||||
|
|
||||||
|
### Веб-сервер и CLI
|
||||||
|
|
||||||
| Crate | Версия | Назначение |
|
| Crate | Версия | Назначение |
|
||||||
|--------------------|--------|-----------------------------------------|
|
|--------------------|--------|-----------------------------------------|
|
||||||
| reqwest | 0.12 | HTTP клиент для коммуникации API |
|
| axum | 0.8 | Веб-фреймворк для REST API |
|
||||||
| serde / serde_json | 1.0 | Сериализация JSON |
|
| tower | 0.5 | Промежуточное ПО для веб-сервера |
|
||||||
| calamine | 0.26 | Парсинг файлов Excel |
|
| tower-http | 0.6 |HTTP утилиты (CORS, статика, трассировка)|
|
||||||
| chrono | 0.4 | Обработка дат и времени |
|
| askama | 0.15 | Шаблонизация (опционально) |
|
||||||
| tokio | 1.0 | Асинхронная среда выполнения |
|
| clap | 4.5 | Парсинг аргументов командной строки |
|
||||||
| dotenv | 0.15 | Конфигурация через переменные окружения |
|
|
||||||
| log / env_logger | 0.11 | Инфраструктура логирования |
|
|
||||||
| thiserror | 2.0 | Определение типов ошибок |
|
|
||||||
| anyhow | 1.0 | Обработка ошибок |
|
|
||||||
|
|
||||||
### Планируемые новые возможности
|
### Платформозависимые зависимости
|
||||||
|
|
||||||
1. Расширить `JobData` в `nexrender.rs` для новых полей данных
|
| Crate | Версия | Платформа | Назначение |
|
||||||
2. Обновить логику парсинга листов при необходимости добавления новых колонок
|
|--------------------|--------|-----------|----------------------------------|
|
||||||
3. Добавить соответствующие слои After Effects в шаблоны
|
| openssl | 0.10 | Linux | Криптография для HTTPS (vendored)|
|
||||||
4. Обновите метод `to_nexrender_job()` с новыми сопоставлениями ресурсов
|
|
||||||
|
|
||||||
## 🙏 Благодарности
|
## 🙏 Благодарности
|
||||||
|
|
||||||
@@ -382,7 +577,7 @@ ae_anons/
|
|||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
**AE Anons** — [MIT License](LICENSE)
|
**AE Anons** — [License](LICENSE)
|
||||||
**Nexrender** — [MIT License](https://github.com/inlife/nexrender/blob/master/LICENSE)
|
**Nexrender** — [MIT License](https://github.com/inlife/nexrender/blob/master/LICENSE)
|
||||||
|
|
||||||
Обе лицензии MIT обеспечивают полную свободу использования и модификации кода.
|
Обе лицензии MIT обеспечивают полную свободу использования и модификации кода.
|
||||||
@@ -407,20 +602,43 @@ ae_anons/
|
|||||||
|
|
||||||
## История изменений
|
## История изменений
|
||||||
|
|
||||||
### v0.1.0
|
### v0.2.2 (текущая)
|
||||||
|
|
||||||
- Первый выпуск
|
- Исправлено отображение имён выходных файлов в веб-интерфейсе
|
||||||
- Интеграция с Synology Office
|
|
||||||
- Базовая генерация заданий Nexrender
|
- Улучшена цветовая схема
|
||||||
- Парсинг Excel с динамическим обнаружением листов
|
|
||||||
- Создание множественных вариантов
|
- Добавлена сортировка по всем колонкам
|
||||||
- Автоматические настройки размера шрифта и позиции
|
|
||||||
- Умное масштабирование логотипов по целевому размеру
|
- Вынесены стили в отдельный CSS файл
|
||||||
|
|
||||||
|
### v0.2.0
|
||||||
|
|
||||||
|
- Добавлен веб-интерфейс для управления заданиями
|
||||||
|
|
||||||
|
- Реализован REST API
|
||||||
|
|
||||||
|
- Добавлена поддержка тёмной/светлой темы
|
||||||
|
|
||||||
|
- Автообновление статуса заданий
|
||||||
|
|
||||||
|
- Возможность остановки всех активных заданий
|
||||||
|
|
||||||
### v0.1.1
|
### v0.1.1
|
||||||
|
|
||||||
- Оптимизированна работат с памятью
|
- Оптимизирована работа с памятью
|
||||||
- Убрана функция создания `.json`
|
|
||||||
|
- Убрана функция создания .json
|
||||||
|
|
||||||
|
### v0.1.0
|
||||||
|
|
||||||
|
- Первый выпуск
|
||||||
|
|
||||||
|
- Интеграция с Synology Office
|
||||||
|
|
||||||
|
- Базовая генерация заданий Nexrender
|
||||||
|
|
||||||
|
- Парсинг Excel с динамическим обнаружением листов
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use dotenv::dotenv;
|
|
||||||
use std::env;
|
use std::env;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -18,12 +17,12 @@ pub struct Config {
|
|||||||
pub template_composition: String,
|
pub template_composition: String,
|
||||||
pub template_output_module: String,
|
pub template_output_module: String,
|
||||||
pub template_output_ext: String,
|
pub template_output_ext: String,
|
||||||
|
//web_serwer
|
||||||
|
pub web_port: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub fn from_env() -> Result<Self> {
|
pub fn from_env() -> Result<Self> {
|
||||||
dotenv().ok();
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
nas_fqdn: env::var("NAS_FQDN").context("NAS_FQDN not set")?,
|
nas_fqdn: env::var("NAS_FQDN").context("NAS_FQDN not set")?,
|
||||||
nas_user: env::var("NAS_USER").context("NAS_USER not set")?,
|
nas_user: env::var("NAS_USER").context("NAS_USER not set")?,
|
||||||
@@ -42,6 +41,10 @@ impl Config {
|
|||||||
.context("TEMPLATE_OUTPUT_MODULE not set")?,
|
.context("TEMPLATE_OUTPUT_MODULE not set")?,
|
||||||
template_output_ext: env::var("TEMPLATE_OUTPUT_EXT")
|
template_output_ext: env::var("TEMPLATE_OUTPUT_EXT")
|
||||||
.context("TEMPLATE_OUTPUT_EXT not set")?,
|
.context("TEMPLATE_OUTPUT_EXT not set")?,
|
||||||
|
web_port: env::var("WEB_PORT")
|
||||||
|
.unwrap_or_else(|_| "3000".to_string())
|
||||||
|
.parse()
|
||||||
|
.context("Invalid WEB_PORT")?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
492
src/main.rs
492
src/main.rs
@@ -1,480 +1,52 @@
|
|||||||
mod config;
|
mod config;
|
||||||
mod nexrender;
|
mod nexrender;
|
||||||
|
mod processor;
|
||||||
mod synology;
|
mod synology;
|
||||||
|
mod web;
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::Result;
|
||||||
use calamine::{Data, Reader, Xlsx};
|
use clap::Parser;
|
||||||
use chrono::{Duration, NaiveDate};
|
|
||||||
use config::Config;
|
use config::Config;
|
||||||
use log::{debug, error, info};
|
use dotenv::dotenv;
|
||||||
use nexrender::{JobData, LogoRegistry};
|
use log::info;
|
||||||
use reqwest::Client;
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::io::Cursor;
|
|
||||||
use std::path::Path;
|
|
||||||
use std::time::Duration as StdDuration;
|
|
||||||
use synology::SynologyClient;
|
|
||||||
use tokio::time::sleep;
|
|
||||||
|
|
||||||
// Структуры для in-memory данных
|
#[derive(Parser)]
|
||||||
#[derive(Debug, Clone)]
|
#[command(author, version, about, long_about = None)]
|
||||||
pub struct SheetData {
|
struct Cli {
|
||||||
pub name: String,
|
/// Run web server instead of one-time processing
|
||||||
pub headers: Vec<String>,
|
#[arg(short, long)]
|
||||||
pub rows: Vec<HashMap<String, String>>,
|
web: bool,
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
/// Run one-time processing (default if no flags)
|
||||||
pub struct ExcelWorkbook {
|
#[arg(short, long)]
|
||||||
pub sheets: Vec<SheetData>,
|
once: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
|
// Загружаем .env до инициализации логгера
|
||||||
|
dotenv().ok();
|
||||||
|
|
||||||
|
// Инициализируем логгер
|
||||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||||
|
|
||||||
info!("Starting AE Anons processor v0.1.1 (in-memory mode)");
|
let cli = Cli::parse();
|
||||||
let config = Config::from_env()?;
|
let config = Config::from_env()?;
|
||||||
debug!("Configuration loaded");
|
|
||||||
|
|
||||||
let mut client = SynologyClient::new(&config.nas_fqdn);
|
if cli.web {
|
||||||
client.login(&config.nas_user, &config.nas_pass).await?;
|
info!(
|
||||||
info!("Successfully authenticated with Synology NAS");
|
"Starting AE Anons web server v{}",
|
||||||
|
env!("CARGO_PKG_VERSION")
|
||||||
let info = client.get_info().await?;
|
|
||||||
info!("Connected to NAS: {}", info.hostname);
|
|
||||||
|
|
||||||
// Скачиваем и парсим Excel в памяти
|
|
||||||
let workbook = download_and_parse_excel_in_memory(&mut client, &config).await?;
|
|
||||||
display_workbook_structure(&workbook);
|
|
||||||
|
|
||||||
process_nexrender_jobs(&workbook, &config).await?;
|
|
||||||
|
|
||||||
client.logout().await?;
|
|
||||||
info!("Session terminated successfully");
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn download_and_parse_excel_in_memory(
|
|
||||||
client: &mut SynologyClient,
|
|
||||||
config: &Config,
|
|
||||||
) -> Result<ExcelWorkbook> {
|
|
||||||
let file_name_full = Path::new(&config.nas_file)
|
|
||||||
.file_name()
|
|
||||||
.and_then(|n| n.to_str())
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
let search_name = file_name_full.replace(".osheet", "");
|
|
||||||
let expected_path = Path::new(&config.nas_file)
|
|
||||||
.parent()
|
|
||||||
.and_then(|p| p.to_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
let api_format_path = expected_path
|
|
||||||
.replace("Team Folder", "team-folders")
|
|
||||||
.replace(' ', "");
|
|
||||||
|
|
||||||
let full_expected_path_api = format!("{}/{}", api_format_path, file_name_full);
|
|
||||||
info!("Searching for file: {}", full_expected_path_api);
|
|
||||||
|
|
||||||
let search_result = client.search_file_by_name(&search_name).await?;
|
|
||||||
let items = search_result["data"]["items"]
|
|
||||||
.as_array()
|
|
||||||
.ok_or_else(|| anyhow!("No items in search result"))?;
|
|
||||||
|
|
||||||
if items.is_empty() {
|
|
||||||
return Err(anyhow!("File '{}' not found", file_name_full));
|
|
||||||
}
|
|
||||||
|
|
||||||
let exact_match: Vec<&Value> = items
|
|
||||||
.iter()
|
|
||||||
.filter(|item| {
|
|
||||||
item.get("display_path")
|
|
||||||
.and_then(|p| p.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
== full_expected_path_api
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if exact_match.is_empty() {
|
|
||||||
return Err(anyhow!("File not found at: {}", full_expected_path_api));
|
|
||||||
}
|
|
||||||
|
|
||||||
let file = exact_match[0];
|
|
||||||
let file_id = file["file_id"]
|
|
||||||
.as_str()
|
|
||||||
.ok_or_else(|| anyhow!("Missing file_id"))?;
|
|
||||||
let actual_file_name = file["name"]
|
|
||||||
.as_str()
|
|
||||||
.ok_or_else(|| anyhow!("Missing name"))?;
|
|
||||||
|
|
||||||
info!("Found file: {}", actual_file_name);
|
|
||||||
info!("Exporting file from Synology Office to Excel format (in-memory)...");
|
|
||||||
|
|
||||||
// Получаем бинарные данные Excel напрямую в память
|
|
||||||
let excel_data = client.export_by_file_id(file_id, actual_file_name).await?;
|
|
||||||
info!("Exported {} bytes to memory", excel_data.len());
|
|
||||||
|
|
||||||
info!("Parsing Excel workbook from memory...");
|
|
||||||
parse_excel_from_bytes(&excel_data)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_excel_from_bytes(data: &[u8]) -> Result<ExcelWorkbook> {
|
|
||||||
let cursor = Cursor::new(data);
|
|
||||||
let mut workbook: Xlsx<_> = calamine::open_workbook_from_rs(cursor)
|
|
||||||
.map_err(|e| anyhow!("Failed to open workbook from memory: {}", e))?;
|
|
||||||
|
|
||||||
let sheet_names = workbook.sheet_names().to_vec();
|
|
||||||
let mut excel_workbook = ExcelWorkbook::default();
|
|
||||||
|
|
||||||
for sheet_name in sheet_names {
|
|
||||||
debug!("Processing sheet: '{}'", sheet_name);
|
|
||||||
|
|
||||||
let range = workbook
|
|
||||||
.worksheet_range(&sheet_name)
|
|
||||||
.map_err(|e| anyhow!("Failed to read sheet '{}': {}", sheet_name, e))?;
|
|
||||||
|
|
||||||
let sheet_data = parse_sheet_dynamic_optimized(&sheet_name, &range)?;
|
|
||||||
excel_workbook.sheets.push(sheet_data);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(excel_workbook)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_sheet_dynamic_optimized(
|
|
||||||
sheet_name: &str,
|
|
||||||
range: &calamine::Range<Data>,
|
|
||||||
) -> Result<SheetData> {
|
|
||||||
// Предварительно выделяем память для избежания реаллокаций
|
|
||||||
let (row_count, col_count) = range.get_size();
|
|
||||||
let mut data_matrix: Vec<Vec<String>> = Vec::with_capacity(row_count);
|
|
||||||
|
|
||||||
// Строим матрицу данных (без enumerate)
|
|
||||||
for row in range.rows() {
|
|
||||||
let mut row_data = Vec::with_capacity(col_count);
|
|
||||||
for cell in row {
|
|
||||||
row_data.push(cell_to_string_optimized(cell));
|
|
||||||
}
|
|
||||||
data_matrix.push(row_data);
|
|
||||||
}
|
|
||||||
if data_matrix.is_empty() {
|
|
||||||
return Ok(SheetData {
|
|
||||||
name: sheet_name.to_string(),
|
|
||||||
headers: Vec::new(),
|
|
||||||
rows: Vec::new(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Извлекаем заголовки с дедупликацией
|
|
||||||
let headers: Vec<String> = data_matrix[0]
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(idx, header)| {
|
|
||||||
let h = header.trim().to_string();
|
|
||||||
if h.is_empty() {
|
|
||||||
format!("Column_{}", idx + 1)
|
|
||||||
} else {
|
|
||||||
h
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Парсим строки данных
|
|
||||||
let rows_data: Vec<HashMap<String, String>> = data_matrix
|
|
||||||
.iter()
|
|
||||||
.skip(1)
|
|
||||||
.filter_map(|row_values| {
|
|
||||||
let mut row_map = HashMap::with_capacity(headers.len());
|
|
||||||
let mut has_data = false;
|
|
||||||
|
|
||||||
for (col_idx, value) in row_values.iter().enumerate() {
|
|
||||||
if col_idx < headers.len() && !value.is_empty() {
|
|
||||||
row_map.insert(headers[col_idx].clone(), value.clone());
|
|
||||||
has_data = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if has_data {
|
|
||||||
Some(row_map)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.take(10000) // Ограничение для безопасности
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(SheetData {
|
|
||||||
name: sheet_name.to_string(),
|
|
||||||
headers,
|
|
||||||
rows: rows_data,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn cell_to_string_optimized(cell: &Data) -> String {
|
|
||||||
match cell {
|
|
||||||
Data::Empty => String::new(),
|
|
||||||
Data::String(s) => s.clone(),
|
|
||||||
Data::Float(f) => {
|
|
||||||
if is_excel_date(*f) {
|
|
||||||
excel_date_to_string(*f)
|
|
||||||
} else if f.fract() == 0.0 {
|
|
||||||
// Используем itoa для целых чисел (опционально)
|
|
||||||
format!("{:.0}", f)
|
|
||||||
} else {
|
|
||||||
// Используем ryu для float (опционально)
|
|
||||||
f.to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Data::Int(i) => {
|
|
||||||
let f = *i as f64;
|
|
||||||
if is_excel_date(f) {
|
|
||||||
excel_date_to_string(f)
|
|
||||||
} else {
|
|
||||||
i.to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Data::Bool(b) => {
|
|
||||||
if *b {
|
|
||||||
"TRUE".to_string()
|
|
||||||
} else {
|
|
||||||
"FALSE".to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Data::DateTime(dt) => {
|
|
||||||
let serial = dt.as_f64();
|
|
||||||
if is_excel_date(serial) {
|
|
||||||
excel_date_to_string(serial)
|
|
||||||
} else {
|
|
||||||
dt.to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Data::DateTimeIso(s) => s.clone(),
|
|
||||||
Data::DurationIso(s) => s.clone(),
|
|
||||||
Data::Error(e) => format!("{:?}", e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn is_excel_date(value: f64) -> bool {
|
|
||||||
(1.0..100000.0).contains(&value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn excel_date_to_string(serial: f64) -> String {
|
|
||||||
let days = serial as i64;
|
|
||||||
let base = NaiveDate::from_ymd_opt(1899, 12, 30).unwrap();
|
|
||||||
if let Some(date) = base.checked_add_signed(Duration::days(days)) {
|
|
||||||
// Используем метод format напрямую - он публичный
|
|
||||||
date.format("%d.%m.%Y").to_string()
|
|
||||||
} else {
|
|
||||||
serial.to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn display_workbook_structure(workbook: &ExcelWorkbook) {
|
|
||||||
info!("Workbook contains {} sheets", workbook.sheets.len());
|
|
||||||
|
|
||||||
for (idx, sheet) in workbook.sheets.iter().enumerate() {
|
|
||||||
debug!(
|
|
||||||
"Sheet #{}: '{}' | Headers: {} | Rows: {}",
|
|
||||||
idx + 1,
|
|
||||||
sheet.name,
|
|
||||||
sheet.headers.len(),
|
|
||||||
sheet.rows.len()
|
|
||||||
);
|
);
|
||||||
}
|
web::run_web_server(config).await?;
|
||||||
}
|
} else {
|
||||||
|
info!(
|
||||||
async fn process_nexrender_jobs(workbook: &ExcelWorkbook, config: &Config) -> Result<()> {
|
"Starting AE Anons processor v{} (one-time mode)",
|
||||||
info!("Preparing Nexrender jobs...");
|
env!("CARGO_PKG_VERSION")
|
||||||
|
);
|
||||||
let start_sheet = workbook
|
let submitted = processor::process_spreadsheet(&config).await?;
|
||||||
.get_sheet("Start")
|
info!("Submitted {} jobs. Exiting.", submitted.len());
|
||||||
.ok_or_else(|| anyhow!("Sheet 'Start' not found"))?;
|
|
||||||
let sport_sheet = workbook
|
|
||||||
.get_sheet("SPORT")
|
|
||||||
.ok_or_else(|| anyhow!("Sheet 'SPORT' not found"))?;
|
|
||||||
let teams_sheet = workbook
|
|
||||||
.get_sheet("TEAMS")
|
|
||||||
.ok_or_else(|| anyhow!("Sheet 'TEAMS' not found"))?;
|
|
||||||
let channel_sheet = workbook
|
|
||||||
.get_sheet("CHANELL")
|
|
||||||
.ok_or_else(|| anyhow!("Sheet 'CHANELL' not found"))?;
|
|
||||||
|
|
||||||
// Используем предварительное выделение памяти
|
|
||||||
let packs: HashMap<String, String> = sport_sheet
|
|
||||||
.rows
|
|
||||||
.iter()
|
|
||||||
.filter_map(|row| Some((row.get("SPORT")?.clone(), row.get("LINK")?.clone())))
|
|
||||||
.collect();
|
|
||||||
info!("Loaded {} sport packs", packs.len());
|
|
||||||
|
|
||||||
let mut logos = LogoRegistry::with_capacity(teams_sheet.rows.len());
|
|
||||||
for row in &teams_sheet.rows {
|
|
||||||
if let (Some(team), Some(sport), Some(link)) =
|
|
||||||
(row.get("TEAM"), row.get("SPORT"), row.get("LINK"))
|
|
||||||
{
|
|
||||||
logos.insert(team.clone(), sport.clone(), link.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
info!("Loaded {} team logos", teams_sheet.rows.len());
|
|
||||||
|
|
||||||
let channels: HashMap<String, String> = channel_sheet
|
|
||||||
.rows
|
|
||||||
.iter()
|
|
||||||
.filter_map(|row| Some((row.get("CHANELL")?.clone(), row.get("LINK")?.clone())))
|
|
||||||
.collect();
|
|
||||||
info!("Loaded {} channel logos", channels.len());
|
|
||||||
|
|
||||||
// Предварительно выделяем память для jobs
|
|
||||||
let mut jobs: Vec<JobData> = Vec::with_capacity(start_sheet.rows.len() * 3);
|
|
||||||
|
|
||||||
for (idx, row) in start_sheet.rows.iter().enumerate() {
|
|
||||||
if let Some(state) = row.get("STATE") {
|
|
||||||
if state == "FALSE" {
|
|
||||||
if let Some(job) = JobData::from_row(row, idx, &packs, &logos, &channels) {
|
|
||||||
jobs.extend(job.create_variants());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("Generated {} total jobs (including variants)", jobs.len());
|
|
||||||
|
|
||||||
if jobs.is_empty() {
|
|
||||||
info!("No jobs with STATE='FALSE' found");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup_finished_jobs(&config.nexrender_api_url).await?;
|
|
||||||
|
|
||||||
let http_client = Client::new();
|
|
||||||
let mut submitted_jobs: Vec<(String, String)> = Vec::with_capacity(jobs.len());
|
|
||||||
|
|
||||||
// Отправляем jobs параллельно для ускорения
|
|
||||||
let mut tasks = Vec::with_capacity(jobs.len());
|
|
||||||
for job in jobs {
|
|
||||||
let client = http_client.clone();
|
|
||||||
let api_url = config.nexrender_api_url.clone();
|
|
||||||
let config_clone = config.clone(); // ✅ Клонируем весь конфиг
|
|
||||||
|
|
||||||
tasks.push(tokio::spawn(async move {
|
|
||||||
let nexrender_job = job.to_nexrender_job(&config_clone); // ✅ Передаём Config
|
|
||||||
info!("Submitting job: {}", job.outfile_name);
|
|
||||||
|
|
||||||
let response = client.post(&api_url).json(&nexrender_job).send().await?;
|
|
||||||
|
|
||||||
if response.status().is_success() {
|
|
||||||
let result: Value = response.json().await?;
|
|
||||||
if let Some(uid) = result.get("uid").and_then(|u| u.as_str()) {
|
|
||||||
Ok::<_, anyhow::Error>(Some((uid.to_string(), job.outfile_name)))
|
|
||||||
} else {
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let status = response.status();
|
|
||||||
let text = response.text().await.unwrap_or_default();
|
|
||||||
error!("Failed to submit job ({}): {}", status, text);
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
for task in tasks {
|
|
||||||
match task.await {
|
|
||||||
Ok(Ok(Some((uid, outfile_name)))) => {
|
|
||||||
info!("Job submitted: {}", outfile_name);
|
|
||||||
submitted_jobs.push((uid, outfile_name));
|
|
||||||
}
|
|
||||||
Ok(Ok(None)) => {
|
|
||||||
debug!("Job submission returned no UID");
|
|
||||||
}
|
|
||||||
Ok(Err(e)) => {
|
|
||||||
error!("Job submission error: {}", e);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!("Task join error: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
info!(
|
|
||||||
"Successfully submitted {} jobs to Nexrender",
|
|
||||||
submitted_jobs.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
if !submitted_jobs.is_empty() {
|
|
||||||
info!("Monitoring job completion...");
|
|
||||||
monitor_jobs(&http_client, &config.nexrender_api_url, submitted_jobs).await?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ... остальные функции без изменений ...
|
|
||||||
impl ExcelWorkbook {
|
|
||||||
pub fn get_sheet(&self, name: &str) -> Option<&SheetData> {
|
|
||||||
self.sheets.iter().find(|s| s.name == name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cleanup_finished_jobs(api_url: &str) -> Result<()> {
|
|
||||||
let client = Client::new();
|
|
||||||
let response = client.get(api_url).send().await?;
|
|
||||||
|
|
||||||
if response.status().is_success() {
|
|
||||||
let jobs: Vec<Value> = response.json().await?;
|
|
||||||
for job in jobs {
|
|
||||||
if let (Some(uid), Some(state)) = (
|
|
||||||
job.get("uid").and_then(|u| u.as_str()),
|
|
||||||
job.get("state").and_then(|s| s.as_str()),
|
|
||||||
) {
|
|
||||||
if state == "finished" || state == "error" {
|
|
||||||
let _ = client.delete(&format!("{}/{}", api_url, uid)).send().await;
|
|
||||||
info!("Cleaned up completed job: {}", uid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn monitor_jobs(
|
|
||||||
client: &Client,
|
|
||||||
api_url: &str,
|
|
||||||
mut pending: Vec<(String, String)>,
|
|
||||||
) -> Result<()> {
|
|
||||||
while !pending.is_empty() {
|
|
||||||
sleep(StdDuration::from_secs(25)).await;
|
|
||||||
|
|
||||||
let mut remaining = Vec::with_capacity(pending.len());
|
|
||||||
for (uid, outname) in pending.drain(..) {
|
|
||||||
let response = client.get(&format!("{}/{}", api_url, uid)).send().await?;
|
|
||||||
|
|
||||||
if response.status().is_success() {
|
|
||||||
let job: Value = response.json().await?;
|
|
||||||
let state = job
|
|
||||||
.get("state")
|
|
||||||
.and_then(|s| s.as_str())
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
|
|
||||||
match state {
|
|
||||||
"finished" => {
|
|
||||||
info!("Job completed: {}", outname)
|
|
||||||
}
|
|
||||||
"error" => error!("Job failed: {}", outname),
|
|
||||||
_ => remaining.push((uid, outname)),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
remaining.push((uid, outname));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pending = remaining;
|
|
||||||
if !pending.is_empty() {
|
|
||||||
info!("Waiting for {} jobs to complete...", pending.len());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
info!("All jobs completed successfully");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -302,7 +302,6 @@ impl JobData {
|
|||||||
variants
|
variants
|
||||||
}
|
}
|
||||||
|
|
||||||
// ЕДИНСТВЕННАЯ реализация метода
|
|
||||||
pub fn to_nexrender_job(&self, config: &crate::config::Config) -> NexrenderJob {
|
pub fn to_nexrender_job(&self, config: &crate::config::Config) -> NexrenderJob {
|
||||||
let template = if self.team_b.is_empty() {
|
let template = if self.team_b.is_empty() {
|
||||||
Template::single(
|
Template::single(
|
||||||
@@ -538,15 +537,23 @@ impl LogoRegistry {
|
|||||||
self.logos.insert((team, sport), link);
|
self.logos.insert((team, sport), link);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// В impl LogoRegistry, функция find()
|
||||||
pub fn find(&self, team: &str, sport: &str) -> Option<String> {
|
pub fn find(&self, team: &str, sport: &str) -> Option<String> {
|
||||||
|
log::debug!("LogoRegistry::find: team='{}', sport='{}'", team, sport);
|
||||||
|
|
||||||
if let Some(link) = self.logos.get(&(team.to_string(), sport.to_string())) {
|
if let Some(link) = self.logos.get(&(team.to_string(), sport.to_string())) {
|
||||||
|
log::debug!("Found exact match: {}", link);
|
||||||
return Some(link.clone());
|
return Some(link.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
for ((t, s), link) in &self.logos {
|
for ((t, s), link) in &self.logos {
|
||||||
if s == sport && team.starts_with(t) {
|
if s == sport && team.starts_with(t) {
|
||||||
|
log::debug!("Found prefix match: t='{}', link='{}'", t, link);
|
||||||
return Some(link.clone());
|
return Some(link.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log::warn!("No logo found for team='{}', sport='{}'", team, sport);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -604,12 +611,26 @@ fn unc_to_uri(path: &str) -> String {
|
|||||||
if path.is_empty() {
|
if path.is_empty() {
|
||||||
return String::new();
|
return String::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Уже правильный URI
|
||||||
if path.starts_with("http://") || path.starts_with("https://") || path.starts_with("file://") {
|
if path.starts_with("http://") || path.starts_with("https://") || path.starts_with("file://") {
|
||||||
return path.to_string();
|
return path.to_string();
|
||||||
}
|
}
|
||||||
format!("file://{}", path.replace('\\', "/").trim_start_matches('/'))
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Mac SMB путь: smb://server/share/path -> file://server/share/path
|
||||||
|
if path.starts_with("smb://") {
|
||||||
|
let without_smb = path.strip_prefix("smb:").unwrap_or(path);
|
||||||
|
return format!("file:{}", without_smb.replace('\\', "/"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows UNC путь: \\server\share\path -> file://server/share/path
|
||||||
|
let normalized = path.replace('\\', "/");
|
||||||
|
|
||||||
|
// Убираем ведущие слеши (для UNC их быть не должно)
|
||||||
|
let trimmed = normalized.trim_start_matches('/');
|
||||||
|
|
||||||
|
format!("file://{}", trimmed)
|
||||||
|
}
|
||||||
fn logo_scale_expression(target_size: &str) -> String {
|
fn logo_scale_expression(target_size: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
"if (width > height) {{max_size = width;}} else {{max_size = height;}} var real_size = {}/max_size*100;[real_size,real_size]",
|
"if (width > height) {{max_size = width;}} else {{max_size = height;}} var real_size = {}/max_size*100;[real_size,real_size]",
|
||||||
|
|||||||
521
src/processor.rs
Normal file
521
src/processor.rs
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
use crate::config::Config;
|
||||||
|
use crate::nexrender::{JobData, LogoRegistry};
|
||||||
|
use crate::synology::SynologyClient;
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use calamine::{Data, Reader, Xlsx};
|
||||||
|
use chrono::{Duration, NaiveDate};
|
||||||
|
use log::{debug, error, info, log_enabled, Level};
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::io::Cursor;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SheetData {
|
||||||
|
pub name: String,
|
||||||
|
pub headers: Vec<String>,
|
||||||
|
pub rows: Vec<HashMap<String, String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct ExcelWorkbook {
|
||||||
|
pub sheets: Vec<SheetData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExcelWorkbook {
|
||||||
|
pub fn get_sheet(&self, name: &str) -> Option<&SheetData> {
|
||||||
|
self.sheets.iter().find(|s| s.name == name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Основная функция обработки: скачивает Excel, генерирует задания, отправляет в Nexrender
|
||||||
|
pub async fn process_spreadsheet(config: &Config) -> Result<Vec<(String, String)>> {
|
||||||
|
let mut client = SynologyClient::new(&config.nas_fqdn);
|
||||||
|
client.login(&config.nas_user, &config.nas_pass).await?;
|
||||||
|
info!("Successfully authenticated with Synology NAS");
|
||||||
|
|
||||||
|
let info = client.get_info().await?;
|
||||||
|
info!("Connected to NAS: {}", info.hostname);
|
||||||
|
|
||||||
|
// Скачиваем и парсим Excel в памяти
|
||||||
|
let workbook = download_and_parse_excel_in_memory(&mut client, config).await?;
|
||||||
|
display_workbook_structure(&workbook);
|
||||||
|
|
||||||
|
let submitted = generate_and_submit_jobs(&workbook, config).await?;
|
||||||
|
|
||||||
|
client.logout().await?;
|
||||||
|
info!("Session terminated successfully");
|
||||||
|
|
||||||
|
Ok(submitted)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download_and_parse_excel_in_memory(
|
||||||
|
client: &mut SynologyClient,
|
||||||
|
config: &Config,
|
||||||
|
) -> Result<ExcelWorkbook> {
|
||||||
|
let file_name_full = Path::new(&config.nas_file)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
let search_name = file_name_full.replace(".osheet", "");
|
||||||
|
let expected_path = Path::new(&config.nas_file)
|
||||||
|
.parent()
|
||||||
|
.and_then(|p| p.to_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
let api_format_path = expected_path
|
||||||
|
.replace("Team Folder", "team-folders")
|
||||||
|
.replace(' ', "");
|
||||||
|
|
||||||
|
let full_expected_path_api = format!("{}/{}", api_format_path, file_name_full);
|
||||||
|
info!("Searching for file: {}", full_expected_path_api);
|
||||||
|
|
||||||
|
let search_result = client.search_file_by_name(&search_name).await?;
|
||||||
|
let items = search_result["data"]["items"]
|
||||||
|
.as_array()
|
||||||
|
.ok_or_else(|| anyhow!("No items in search result"))?;
|
||||||
|
|
||||||
|
if items.is_empty() {
|
||||||
|
return Err(anyhow!("File '{}' not found", file_name_full));
|
||||||
|
}
|
||||||
|
|
||||||
|
let exact_match: Vec<&Value> = items
|
||||||
|
.iter()
|
||||||
|
.filter(|item| {
|
||||||
|
item.get("display_path")
|
||||||
|
.and_then(|p| p.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
== full_expected_path_api
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if exact_match.is_empty() {
|
||||||
|
return Err(anyhow!("File not found at: {}", full_expected_path_api));
|
||||||
|
}
|
||||||
|
|
||||||
|
let file = exact_match[0];
|
||||||
|
let file_id = file["file_id"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow!("Missing file_id"))?;
|
||||||
|
let actual_file_name = file["name"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow!("Missing name"))?;
|
||||||
|
|
||||||
|
info!("Found file: {}", actual_file_name);
|
||||||
|
info!("Exporting file from Synology Office to Excel format (in-memory)...");
|
||||||
|
|
||||||
|
// Получаем бинарные данные Excel напрямую в память
|
||||||
|
let excel_data = client.export_by_file_id(file_id, actual_file_name).await?;
|
||||||
|
info!("Exported {} bytes to memory", excel_data.len());
|
||||||
|
|
||||||
|
info!("Parsing Excel workbook from memory...");
|
||||||
|
parse_excel_from_bytes(&excel_data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_excel_from_bytes(data: &[u8]) -> Result<ExcelWorkbook> {
|
||||||
|
let cursor = Cursor::new(data);
|
||||||
|
let mut workbook: Xlsx<_> = calamine::open_workbook_from_rs(cursor)
|
||||||
|
.map_err(|e| anyhow!("Failed to open workbook from memory: {}", e))?;
|
||||||
|
|
||||||
|
let sheet_names = workbook.sheet_names().to_vec();
|
||||||
|
let mut excel_workbook = ExcelWorkbook::default();
|
||||||
|
|
||||||
|
for sheet_name in sheet_names {
|
||||||
|
debug!("Processing sheet: '{}'", sheet_name);
|
||||||
|
|
||||||
|
let range = workbook
|
||||||
|
.worksheet_range(&sheet_name)
|
||||||
|
.map_err(|e| anyhow!("Failed to read sheet '{}': {}", sheet_name, e))?;
|
||||||
|
|
||||||
|
let sheet_data = parse_sheet_dynamic_optimized(&sheet_name, &range)?;
|
||||||
|
excel_workbook.sheets.push(sheet_data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(excel_workbook)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_sheet_dynamic_optimized(
|
||||||
|
sheet_name: &str,
|
||||||
|
range: &calamine::Range<Data>,
|
||||||
|
) -> Result<SheetData> {
|
||||||
|
let (row_count, col_count) = range.get_size();
|
||||||
|
let mut data_matrix: Vec<Vec<String>> = Vec::with_capacity(row_count);
|
||||||
|
|
||||||
|
for row in range.rows() {
|
||||||
|
let mut row_data = Vec::with_capacity(col_count);
|
||||||
|
for cell in row {
|
||||||
|
row_data.push(cell_to_string_optimized(cell));
|
||||||
|
}
|
||||||
|
data_matrix.push(row_data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if data_matrix.is_empty() {
|
||||||
|
return Ok(SheetData {
|
||||||
|
name: sheet_name.to_string(),
|
||||||
|
headers: Vec::new(),
|
||||||
|
rows: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Извлекаем заголовки с дедупликацией
|
||||||
|
let headers: Vec<String> = data_matrix[0]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(idx, header)| {
|
||||||
|
let h = header.trim().to_string();
|
||||||
|
if h.is_empty() {
|
||||||
|
format!("Column_{}", idx + 1)
|
||||||
|
} else {
|
||||||
|
h
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let rows_data: Vec<HashMap<String, String>> = data_matrix
|
||||||
|
.iter()
|
||||||
|
.skip(1)
|
||||||
|
.filter_map(|row_values| {
|
||||||
|
let mut row_map = HashMap::with_capacity(headers.len());
|
||||||
|
let mut has_data = false;
|
||||||
|
|
||||||
|
for (col_idx, value) in row_values.iter().enumerate() {
|
||||||
|
if col_idx < headers.len() && !value.is_empty() {
|
||||||
|
row_map.insert(headers[col_idx].clone(), value.clone());
|
||||||
|
has_data = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_data {
|
||||||
|
Some(row_map)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.take(10000) // Ограничение для безопасности
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(SheetData {
|
||||||
|
name: sheet_name.to_string(),
|
||||||
|
headers,
|
||||||
|
rows: rows_data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn cell_to_string_optimized(cell: &Data) -> String {
|
||||||
|
match cell {
|
||||||
|
Data::Empty => String::new(),
|
||||||
|
Data::String(s) => s.clone(),
|
||||||
|
Data::Float(f) => {
|
||||||
|
if is_excel_date(*f) {
|
||||||
|
excel_date_to_string(*f)
|
||||||
|
} else if f.fract() == 0.0 {
|
||||||
|
format!("{:.0}", f)
|
||||||
|
} else {
|
||||||
|
f.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Data::Int(i) => {
|
||||||
|
let f = *i as f64;
|
||||||
|
if is_excel_date(f) {
|
||||||
|
excel_date_to_string(f)
|
||||||
|
} else {
|
||||||
|
i.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Data::Bool(b) => {
|
||||||
|
if *b {
|
||||||
|
"TRUE".to_string()
|
||||||
|
} else {
|
||||||
|
"FALSE".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Data::DateTime(dt) => {
|
||||||
|
let serial = dt.as_f64();
|
||||||
|
if is_excel_date(serial) {
|
||||||
|
excel_date_to_string(serial)
|
||||||
|
} else {
|
||||||
|
dt.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Data::DateTimeIso(s) => s.clone(),
|
||||||
|
Data::DurationIso(s) => s.clone(),
|
||||||
|
Data::Error(e) => format!("{:?}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn is_excel_date(value: f64) -> bool {
|
||||||
|
(1.0..100000.0).contains(&value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn excel_date_to_string(serial: f64) -> String {
|
||||||
|
let days = serial as i64;
|
||||||
|
let base = NaiveDate::from_ymd_opt(1899, 12, 30).unwrap();
|
||||||
|
if let Some(date) = base.checked_add_signed(Duration::days(days)) {
|
||||||
|
date.format("%d.%m.%Y").to_string()
|
||||||
|
} else {
|
||||||
|
serial.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display_workbook_structure(workbook: &ExcelWorkbook) {
|
||||||
|
info!("Workbook contains {} sheets", workbook.sheets.len());
|
||||||
|
|
||||||
|
for (idx, sheet) in workbook.sheets.iter().enumerate() {
|
||||||
|
debug!(
|
||||||
|
"Sheet #{}: '{}' | Headers: {} | Rows: {}",
|
||||||
|
idx + 1,
|
||||||
|
sheet.name,
|
||||||
|
sheet.headers.len(),
|
||||||
|
sheet.rows.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn generate_and_submit_jobs(
|
||||||
|
workbook: &ExcelWorkbook,
|
||||||
|
config: &Config,
|
||||||
|
) -> Result<Vec<(String, String)>> {
|
||||||
|
info!("Preparing Nexrender jobs...");
|
||||||
|
|
||||||
|
let start_sheet = workbook
|
||||||
|
.get_sheet("Start")
|
||||||
|
.ok_or_else(|| anyhow!("Sheet 'Start' not found"))?;
|
||||||
|
let sport_sheet = workbook
|
||||||
|
.get_sheet("SPORT")
|
||||||
|
.ok_or_else(|| anyhow!("Sheet 'SPORT' not found"))?;
|
||||||
|
let teams_sheet = workbook
|
||||||
|
.get_sheet("TEAMS")
|
||||||
|
.ok_or_else(|| anyhow!("Sheet 'TEAMS' not found"))?;
|
||||||
|
let channel_sheet = workbook
|
||||||
|
.get_sheet("CHANELL")
|
||||||
|
.ok_or_else(|| anyhow!("Sheet 'CHANELL' not found"))?;
|
||||||
|
|
||||||
|
// Загружаем sport packs
|
||||||
|
let packs: HashMap<String, String> = sport_sheet
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.filter_map(|row| Some((row.get("SPORT")?.clone(), row.get("LINK")?.clone())))
|
||||||
|
.collect();
|
||||||
|
info!("Loaded {} sport packs", packs.len());
|
||||||
|
|
||||||
|
// Вспомогательная функция для поиска значения по части ключа
|
||||||
|
fn get_cell_fuzzy(row: &HashMap<String, String>, key_part: &str) -> Option<String> {
|
||||||
|
// Точное совпадение
|
||||||
|
if let Some(val) = row.get(key_part) {
|
||||||
|
if !val.is_empty() {
|
||||||
|
return Some(val.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Поиск по содержанию (без учёта регистра)
|
||||||
|
for (k, v) in row.iter() {
|
||||||
|
if k.to_lowercase().contains(&key_part.to_lowercase()) && !v.is_empty() {
|
||||||
|
return Some(v.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем team logos
|
||||||
|
debug!("TEAMS headers: {:?}", teams_sheet.headers);
|
||||||
|
for (i, row) in teams_sheet.rows.iter().take(3).enumerate() {
|
||||||
|
debug!("TEAMS row {}: {:?}", i, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut logos = LogoRegistry::with_capacity(teams_sheet.rows.len());
|
||||||
|
for row in &teams_sheet.rows {
|
||||||
|
let team = get_cell_fuzzy(row, "TEAM");
|
||||||
|
let sport = get_cell_fuzzy(row, "SPORT");
|
||||||
|
let link = get_cell_fuzzy(row, "LINK");
|
||||||
|
|
||||||
|
if let (Some(team), Some(sport), Some(link)) = (team, sport, link) {
|
||||||
|
logos.insert(team.clone(), sport.clone(), link.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info!("Loaded {} team logos", teams_sheet.rows.len());
|
||||||
|
|
||||||
|
// Дебаг: выводим статистику по TEAMS
|
||||||
|
if log_enabled!(Level::Debug) {
|
||||||
|
let sports: HashSet<_> = teams_sheet
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.filter_map(|r| r.get("SPORT"))
|
||||||
|
.collect();
|
||||||
|
debug!("Sports found in TEAMS sheet: {:?}", sports);
|
||||||
|
|
||||||
|
for sport in sports.iter() {
|
||||||
|
let examples: Vec<_> = teams_sheet
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.filter(|r| r.get("SPORT") == Some(*sport))
|
||||||
|
.take(5)
|
||||||
|
.filter_map(|r| r.get("TEAM"))
|
||||||
|
.collect();
|
||||||
|
debug!(" TEAMS examples for '{}': {:?}", sport, examples);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем channel logos
|
||||||
|
let channels: HashMap<String, String> = channel_sheet
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.filter_map(|row| Some((row.get("CHANELL")?.clone(), row.get("LINK")?.clone())))
|
||||||
|
.collect();
|
||||||
|
info!("Loaded {} channel logos", channels.len());
|
||||||
|
|
||||||
|
if log_enabled!(Level::Debug) {
|
||||||
|
debug!("Channels loaded: {:?}", channels.keys().collect::<Vec<_>>());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut jobs: Vec<JobData> = Vec::with_capacity(start_sheet.rows.len() * 3);
|
||||||
|
|
||||||
|
// Собираем статистику по ненайденным логотипам для дебага
|
||||||
|
let mut missing_teams: HashSet<String> = HashSet::new();
|
||||||
|
let mut missing_sports: HashSet<String> = HashSet::new();
|
||||||
|
|
||||||
|
for (idx, row) in start_sheet.rows.iter().enumerate() {
|
||||||
|
if let Some(state) = row.get("STATE") {
|
||||||
|
if state == "FALSE" {
|
||||||
|
let team_a = row.get("TEAM A").cloned().unwrap_or_default();
|
||||||
|
let team_b = row.get("TEAM B").cloned().unwrap_or_default();
|
||||||
|
let sport = row.get("SPORT").cloned().unwrap_or_default();
|
||||||
|
|
||||||
|
if log_enabled!(Level::Debug) {
|
||||||
|
let logo_a = logos.find(&team_a, &sport);
|
||||||
|
let logo_b = logos.find(&team_b, &sport);
|
||||||
|
|
||||||
|
if !team_a.is_empty() && logo_a.is_none() {
|
||||||
|
missing_teams.insert(team_a.clone());
|
||||||
|
missing_sports.insert(sport.clone());
|
||||||
|
}
|
||||||
|
if !team_b.is_empty() && logo_b.is_none() {
|
||||||
|
missing_teams.insert(team_b.clone());
|
||||||
|
missing_sports.insert(sport.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(job) = JobData::from_row(row, idx, &packs, &logos, &channels) {
|
||||||
|
jobs.extend(job.create_variants());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Дебаг: выводим сводку по отсутствующим логотипам
|
||||||
|
if log_enabled!(Level::Debug) && !missing_teams.is_empty() {
|
||||||
|
debug!("=== MISSING LOGOS SUMMARY ===");
|
||||||
|
debug!("Total unique teams missing logos: {}", missing_teams.len());
|
||||||
|
debug!("Sports involved: {:?}", missing_sports);
|
||||||
|
debug!(
|
||||||
|
"Missing teams (first 20): {:?}",
|
||||||
|
missing_teams.iter().take(20).collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
debug!("==============================");
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Generated {} total jobs (including variants)", jobs.len());
|
||||||
|
|
||||||
|
if jobs.is_empty() {
|
||||||
|
info!("No jobs with STATE='FALSE' found");
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_finished_jobs(&config.nexrender_api_url).await?;
|
||||||
|
|
||||||
|
let http_client = Client::new();
|
||||||
|
let mut submitted_jobs: Vec<(String, String)> = Vec::with_capacity(jobs.len());
|
||||||
|
|
||||||
|
let mut tasks = Vec::with_capacity(jobs.len());
|
||||||
|
for job in jobs {
|
||||||
|
let client = http_client.clone();
|
||||||
|
let api_url = config.nexrender_api_url.clone();
|
||||||
|
let config_clone = config.clone();
|
||||||
|
|
||||||
|
tasks.push(tokio::spawn(async move {
|
||||||
|
let nexrender_job = job.to_nexrender_job(&config_clone);
|
||||||
|
info!("Submitting job: {}", job.outfile_name);
|
||||||
|
|
||||||
|
if log_enabled!(Level::Debug) {
|
||||||
|
debug!(
|
||||||
|
"Job details: sport='{}', league='{}', channel='{}', team_a='{}', team_b='{}'",
|
||||||
|
job.sport, job.league, job.channel, job.team_a, job.team_b
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = client.post(&api_url).json(&nexrender_job).send().await?;
|
||||||
|
|
||||||
|
if response.status().is_success() {
|
||||||
|
let result: Value = response.json().await?;
|
||||||
|
if let Some(uid) = result.get("uid").and_then(|u| u.as_str()) {
|
||||||
|
Ok::<_, anyhow::Error>(Some((uid.to_string(), job.outfile_name)))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let status = response.status();
|
||||||
|
let text = response.text().await.unwrap_or_default();
|
||||||
|
error!("Failed to submit job ({}): {}", status, text);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
for task in tasks {
|
||||||
|
match task.await {
|
||||||
|
Ok(Ok(Some((uid, outfile_name)))) => {
|
||||||
|
info!("Job submitted: {}", outfile_name);
|
||||||
|
submitted_jobs.push((uid, outfile_name));
|
||||||
|
}
|
||||||
|
Ok(Ok(None)) => {
|
||||||
|
debug!("Job submission returned no UID");
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
error!("Job submission error: {}", e);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Task join error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Successfully submitted {} jobs to Nexrender",
|
||||||
|
submitted_jobs.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(submitted_jobs)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cleanup_finished_jobs(api_url: &str) -> Result<()> {
|
||||||
|
let client = Client::new();
|
||||||
|
let response = client.get(api_url).send().await?;
|
||||||
|
|
||||||
|
if response.status().is_success() {
|
||||||
|
let jobs: Vec<Value> = response.json().await?;
|
||||||
|
for job in jobs {
|
||||||
|
if let (Some(uid), Some(state)) = (
|
||||||
|
job.get("uid").and_then(|u| u.as_str()),
|
||||||
|
job.get("state").and_then(|s| s.as_str()),
|
||||||
|
) {
|
||||||
|
if state == "finished" || state == "error" {
|
||||||
|
let _ = client.delete(&format!("{}/{}", api_url, uid)).send().await;
|
||||||
|
info!("Cleaned up completed job: {}", uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_all_jobs(api_url: &str) -> Result<Vec<Value>> {
|
||||||
|
let client = Client::new();
|
||||||
|
let response = client.get(api_url).send().await?;
|
||||||
|
if response.status().is_success() {
|
||||||
|
let jobs: Vec<Value> = response.json().await?;
|
||||||
|
Ok(jobs)
|
||||||
|
} else {
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
489
src/static/index.html
Normal file
489
src/static/index.html
Normal file
@@ -0,0 +1,489 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>AE Anons - Nexrender Job Manager</title>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="header">
|
||||||
|
<div class="header-left">
|
||||||
|
<div class="logo-container">
|
||||||
|
<img src="/assets/logo.png" alt="AE Anons Logo" class="logo" id="logo"
|
||||||
|
onerror="this.style.display='none'; document.getElementById('logoPlaceholder').style.display='flex';">
|
||||||
|
<div class="logo-placeholder" id="logoPlaceholder" style="display: none;">
|
||||||
|
<i class="fas fa-bolt"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1>AE Anons - Nexrender Job Manager</h1>
|
||||||
|
</div>
|
||||||
|
<div class="header-controls">
|
||||||
|
<button class="theme-toggle" onclick="toggleTheme()">
|
||||||
|
<i class="fas fa-circle-half-stroke" id="themeIcon"></i>
|
||||||
|
<span id="themeText">Auto</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Bar -->
|
||||||
|
<div class="action-bar">
|
||||||
|
<button class="btn btn-primary" onclick="generateJobs()">
|
||||||
|
<i class="fas fa-play"></i> Generate New Jobs
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-warning" onclick="stopAllJobs()">
|
||||||
|
<i class="fas fa-stop"></i> Stop All Jobs
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-danger" onclick="cleanupJobs()">
|
||||||
|
<i class="fas fa-trash-alt"></i> Cleanup Finished
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline" onclick="refreshJobs()">
|
||||||
|
<i class="fas fa-sync-alt"></i> Refresh
|
||||||
|
</button>
|
||||||
|
<div class="status-message" id="statusMessage">
|
||||||
|
<i class="fas fa-circle" style="color: var(--accent-success); font-size: 8px;"></i>
|
||||||
|
<span>Ready</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Grid -->
|
||||||
|
<div class="stats-grid" id="statsGrid"></div>
|
||||||
|
|
||||||
|
<!-- Filter Bar -->
|
||||||
|
<div class="filter-bar">
|
||||||
|
<div class="search-wrapper">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
<input type="text" class="search-input" id="filterInput" placeholder="Filter by filename or UID..."
|
||||||
|
onkeyup="filterTable()">
|
||||||
|
</div>
|
||||||
|
<div class="auto-refresh-badge">
|
||||||
|
<i class="fas fa-clock"></i>
|
||||||
|
<span>Auto-refresh: 60s</span>
|
||||||
|
<span id="refreshCountdown">60</span>s
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div class="table-container">
|
||||||
|
<table id="jobsTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th data-column="filename" onclick="sortTable('filename')">
|
||||||
|
Output File <i class="fas fa-sort"></i>
|
||||||
|
</th>
|
||||||
|
<th data-column="state" onclick="sortTable('state')" class="sorted-asc">
|
||||||
|
State <i class="fas fa-sort-up"></i>
|
||||||
|
</th>
|
||||||
|
<th data-column="created" onclick="sortTable('created')">
|
||||||
|
Created <i class="fas fa-sort"></i>
|
||||||
|
</th>
|
||||||
|
<th data-column="updated" onclick="sortTable('updated')">
|
||||||
|
Updated <i class="fas fa-sort"></i>
|
||||||
|
</th>
|
||||||
|
<th data-column="uid" onclick="sortTable('uid')">
|
||||||
|
UID <i class="fas fa-sort"></i>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="jobsTableBody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="5">
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
|
<p>Loading jobs...</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// State
|
||||||
|
let allJobs = [];
|
||||||
|
let filteredJobs = [];
|
||||||
|
let isLoading = false;
|
||||||
|
let autoRefreshTimer = null;
|
||||||
|
let countdownTimer = null;
|
||||||
|
let countdownValue = 60;
|
||||||
|
let currentTheme = 'auto';
|
||||||
|
|
||||||
|
// Sorting state
|
||||||
|
let currentSort = { column: 'state', direction: 'asc' };
|
||||||
|
const stateOrder = { 'finished': 1, 'started': 2, 'processing': 3, 'queued': 4, 'pending': 5, 'error': 6 };
|
||||||
|
|
||||||
|
// Theme handling
|
||||||
|
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
|
||||||
|
function getOSPreference() {
|
||||||
|
return darkModeMediaQuery.matches ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(theme) {
|
||||||
|
const effectiveTheme = theme === 'auto' ? getOSPreference() : theme;
|
||||||
|
document.documentElement.setAttribute('data-theme', effectiveTheme);
|
||||||
|
updateThemeButton(theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateThemeButton(theme) {
|
||||||
|
const icon = document.getElementById('themeIcon');
|
||||||
|
const text = document.getElementById('themeText');
|
||||||
|
|
||||||
|
if (theme === 'auto') {
|
||||||
|
icon.className = 'fas fa-circle-half-stroke';
|
||||||
|
text.textContent = 'Auto';
|
||||||
|
} else if (theme === 'dark') {
|
||||||
|
icon.className = 'fas fa-moon';
|
||||||
|
text.textContent = 'Dark';
|
||||||
|
} else {
|
||||||
|
icon.className = 'fas fa-sun';
|
||||||
|
text.textContent = 'Light';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
if (currentTheme === 'auto') {
|
||||||
|
currentTheme = 'light';
|
||||||
|
} else if (currentTheme === 'light') {
|
||||||
|
currentTheme = 'dark';
|
||||||
|
} else {
|
||||||
|
currentTheme = 'auto';
|
||||||
|
}
|
||||||
|
localStorage.setItem('theme', currentTheme);
|
||||||
|
applyTheme(currentTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTheme() {
|
||||||
|
const savedTheme = localStorage.getItem('theme') || 'auto';
|
||||||
|
currentTheme = savedTheme;
|
||||||
|
applyTheme(currentTheme);
|
||||||
|
|
||||||
|
darkModeMediaQuery.addEventListener('change', (e) => {
|
||||||
|
if (currentTheme === 'auto') {
|
||||||
|
applyTheme('auto');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sorting
|
||||||
|
function sortTable(column) {
|
||||||
|
if (currentSort.column === column) {
|
||||||
|
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
currentSort.column = column;
|
||||||
|
currentSort.direction = 'asc';
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSortIcons();
|
||||||
|
sortAndRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSortIcons() {
|
||||||
|
document.querySelectorAll('th').forEach(th => {
|
||||||
|
const column = th.dataset.column;
|
||||||
|
th.classList.remove('sorted-asc', 'sorted-desc');
|
||||||
|
|
||||||
|
const icon = th.querySelector('i');
|
||||||
|
if (column === currentSort.column) {
|
||||||
|
th.classList.add(currentSort.direction === 'asc' ? 'sorted-asc' : 'sorted-desc');
|
||||||
|
icon.className = currentSort.direction === 'asc' ? 'fas fa-sort-up' : 'fas fa-sort-down';
|
||||||
|
} else {
|
||||||
|
icon.className = 'fas fa-sort';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortJobs(jobs) {
|
||||||
|
const { column, direction } = currentSort;
|
||||||
|
const multiplier = direction === 'asc' ? 1 : -1;
|
||||||
|
|
||||||
|
return [...jobs].sort((a, b) => {
|
||||||
|
let aVal, bVal;
|
||||||
|
|
||||||
|
switch (column) {
|
||||||
|
case 'filename':
|
||||||
|
aVal = a.outfile_name || '';
|
||||||
|
bVal = b.outfile_name || '';
|
||||||
|
return multiplier * aVal.localeCompare(bVal);
|
||||||
|
|
||||||
|
case 'state':
|
||||||
|
aVal = stateOrder[a.state] || 999;
|
||||||
|
bVal = stateOrder[b.state] || 999;
|
||||||
|
return multiplier * (aVal - bVal);
|
||||||
|
|
||||||
|
case 'created':
|
||||||
|
aVal = a.created_at ? new Date(a.created_at).getTime() : 0;
|
||||||
|
bVal = b.created_at ? new Date(b.created_at).getTime() : 0;
|
||||||
|
return multiplier * (bVal - aVal);
|
||||||
|
|
||||||
|
case 'updated':
|
||||||
|
aVal = a.updated_at ? new Date(a.updated_at).getTime() : 0;
|
||||||
|
bVal = b.updated_at ? new Date(b.updated_at).getTime() : 0;
|
||||||
|
return multiplier * (bVal - aVal);
|
||||||
|
|
||||||
|
case 'uid':
|
||||||
|
aVal = a.uid || '';
|
||||||
|
bVal = b.uid || '';
|
||||||
|
return multiplier * aVal.localeCompare(bVal);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortAndRender() {
|
||||||
|
const jobsToRender = filteredJobs.length > 0 || document.getElementById('filterInput').value ?
|
||||||
|
filteredJobs : allJobs;
|
||||||
|
const sorted = sortJobs(jobsToRender);
|
||||||
|
renderJobs(sorted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data fetching
|
||||||
|
async function refreshJobs() {
|
||||||
|
if (isLoading) return;
|
||||||
|
isLoading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setStatus('loading', 'Loading jobs...');
|
||||||
|
const response = await fetch('/api/jobs');
|
||||||
|
allJobs = await response.json();
|
||||||
|
filteredJobs = [];
|
||||||
|
document.getElementById('filterInput').value = '';
|
||||||
|
sortAndRender();
|
||||||
|
updateStats();
|
||||||
|
setStatus('success', `Loaded ${allJobs.length} jobs`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setStatus('error', 'Failed to load jobs');
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderJobs(jobs) {
|
||||||
|
const tbody = document.getElementById('jobsTableBody');
|
||||||
|
|
||||||
|
if (jobs.length === 0) {
|
||||||
|
tbody.innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td colspan="5">
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="fas fa-inbox"></i>
|
||||||
|
<p>No jobs found</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = jobs.map(job => {
|
||||||
|
const stateClass = getStateClass(job.state);
|
||||||
|
const created = formatDateTime(job.created_at);
|
||||||
|
const updated = formatDateTime(job.updated_at);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="job-filename" title="${escapeHtml(job.outfile_name)}">${escapeHtml(job.outfile_name)}</div>
|
||||||
|
</td>
|
||||||
|
<td><span class="badge ${stateClass}">${escapeHtml(job.state)}</span></td>
|
||||||
|
<td class="datetime">${created}</td>
|
||||||
|
<td class="datetime">${updated}</td>
|
||||||
|
<td class="uid" title="${escapeHtml(job.uid)}">${job.uid.substring(0, 10)}...</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStateClass(state) {
|
||||||
|
const classes = {
|
||||||
|
'finished': 'badge-finished',
|
||||||
|
'started': 'badge-started',
|
||||||
|
'processing': 'badge-processing',
|
||||||
|
'queued': 'badge-queued',
|
||||||
|
'error': 'badge-error',
|
||||||
|
'pending': 'badge-pending'
|
||||||
|
};
|
||||||
|
return classes[state] || 'badge-pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(dateStr) {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
try {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const time = date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
const dayMonth = date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' });
|
||||||
|
return `${time}, ${dayMonth}`;
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStats() {
|
||||||
|
const stats = {
|
||||||
|
total: allJobs.length,
|
||||||
|
finished: 0,
|
||||||
|
started: 0,
|
||||||
|
queued: 0,
|
||||||
|
error: 0,
|
||||||
|
other: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
allJobs.forEach(job => {
|
||||||
|
switch (job.state) {
|
||||||
|
case 'finished': stats.finished++; break;
|
||||||
|
case 'started':
|
||||||
|
case 'processing': stats.started++; break;
|
||||||
|
case 'queued': stats.queued++; break;
|
||||||
|
case 'error': stats.error++; break;
|
||||||
|
default: stats.other++; break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const grid = document.getElementById('statsGrid');
|
||||||
|
grid.innerHTML = `
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3><i class="fas fa-tasks"></i> Total Jobs</h3>
|
||||||
|
<div class="value">${stats.total}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3><i class="fas fa-check-circle"></i> Completed</h3>
|
||||||
|
<div class="value">${stats.finished}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3><i class="fas fa-play-circle"></i> Active</h3>
|
||||||
|
<div class="value">${stats.started + stats.queued}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3><i class="fas fa-exclamation-circle"></i> Errors</h3>
|
||||||
|
<div class="value">${stats.error}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterTable() {
|
||||||
|
const filter = document.getElementById('filterInput').value.toLowerCase();
|
||||||
|
filteredJobs = allJobs.filter(job =>
|
||||||
|
job.outfile_name.toLowerCase().includes(filter) ||
|
||||||
|
job.uid.toLowerCase().includes(filter)
|
||||||
|
);
|
||||||
|
sortAndRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
async function generateJobs() {
|
||||||
|
setStatus('loading', 'Generating jobs...');
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/generate', { method: 'POST' });
|
||||||
|
if (response.ok) {
|
||||||
|
setStatus('success', 'Job generation started');
|
||||||
|
setTimeout(() => refreshJobs(), 5000);
|
||||||
|
} else {
|
||||||
|
const text = await response.text();
|
||||||
|
setStatus('error', `Error: ${text}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setStatus('error', `Error: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopAllJobs() {
|
||||||
|
if (!confirm('Are you sure you want to stop all active jobs?')) return;
|
||||||
|
|
||||||
|
setStatus('loading', 'Stopping all jobs...');
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/jobs/stop-all', { method: 'POST' });
|
||||||
|
if (response.ok) {
|
||||||
|
setStatus('success', 'All jobs stopped');
|
||||||
|
refreshJobs();
|
||||||
|
} else {
|
||||||
|
const text = await response.text();
|
||||||
|
setStatus('error', `Error: ${text}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setStatus('error', `Error: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupJobs() {
|
||||||
|
setStatus('loading', 'Cleaning up finished jobs...');
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/cleanup', { method: 'POST' });
|
||||||
|
if (response.ok) {
|
||||||
|
setStatus('success', 'Cleanup completed');
|
||||||
|
await refreshJobs();
|
||||||
|
} else {
|
||||||
|
const text = await response.text();
|
||||||
|
setStatus('error', `Error: ${text}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setStatus('error', `Error: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(type, message) {
|
||||||
|
const el = document.getElementById('statusMessage');
|
||||||
|
const icons = {
|
||||||
|
loading: '<span class="spinner"></span>',
|
||||||
|
success: '<i class="fas fa-check-circle" style="color: var(--accent-success);"></i>',
|
||||||
|
error: '<i class="fas fa-times-circle" style="color: var(--accent-danger);"></i>'
|
||||||
|
};
|
||||||
|
el.innerHTML = `${icons[type] || ''} <span>${message}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-refresh
|
||||||
|
function startAutoRefresh() {
|
||||||
|
stopAutoRefresh();
|
||||||
|
|
||||||
|
autoRefreshTimer = setInterval(() => {
|
||||||
|
refreshJobs();
|
||||||
|
resetCountdown();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
countdownTimer = setInterval(() => {
|
||||||
|
countdownValue--;
|
||||||
|
document.getElementById('refreshCountdown').textContent = countdownValue;
|
||||||
|
if (countdownValue <= 0) {
|
||||||
|
countdownValue = 60;
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAutoRefresh() {
|
||||||
|
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
|
||||||
|
if (countdownTimer) clearInterval(countdownTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCountdown() {
|
||||||
|
countdownValue = 60;
|
||||||
|
document.getElementById('refreshCountdown').textContent = countdownValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
initTheme();
|
||||||
|
refreshJobs();
|
||||||
|
startAutoRefresh();
|
||||||
|
updateSortIcons();
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
stopAutoRefresh();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
555
src/static/style.css
Normal file
555
src/static/style.css
Normal file
@@ -0,0 +1,555 @@
|
|||||||
|
/* ========================================
|
||||||
|
AE Anons - Nexrender Job Manager Styles
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
/* CSS Variables - Light Theme (default) */
|
||||||
|
:root {
|
||||||
|
--bg-primary: #ffffff;
|
||||||
|
--bg-secondary: #fafafa;
|
||||||
|
--bg-tertiary: #f3f0f7;
|
||||||
|
--text-primary: #1a1a1a;
|
||||||
|
--text-secondary: #6b4f7c;
|
||||||
|
--border-color: #e0d4e8;
|
||||||
|
--accent-primary: #7c3aed;
|
||||||
|
--accent-secondary: #c2410c;
|
||||||
|
--accent-success: #10b981;
|
||||||
|
--accent-warning: #ea580c;
|
||||||
|
--accent-danger: #ef4444;
|
||||||
|
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
||||||
|
--header-bg: #7c3aed;
|
||||||
|
--header-text: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark Theme */
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--bg-primary: #1a1025;
|
||||||
|
--bg-secondary: #251a30;
|
||||||
|
--bg-tertiary: #352545;
|
||||||
|
--text-primary: #f0e6ff;
|
||||||
|
--text-secondary: #c4a6d9;
|
||||||
|
--border-color: #4a3560;
|
||||||
|
--accent-primary: #a78bfa;
|
||||||
|
--accent-secondary: #f59e0b;
|
||||||
|
--accent-success: #34d399;
|
||||||
|
--accent-warning: #fbbf24;
|
||||||
|
--accent-danger: #f87171;
|
||||||
|
--header-bg: #a78bfa;
|
||||||
|
--header-text: #1a1025;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reset & Base */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.6;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-container {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--accent-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-primary);
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 2px solid var(--accent-primary);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action Bar */
|
||||||
|
.action-bar {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn i {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Generate - Purple */
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #7c3aed 0%, #8b5cf6 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 2px 8px rgba(124, 58, 237, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .btn-primary {
|
||||||
|
background: linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%);
|
||||||
|
color: #1a1025;
|
||||||
|
box-shadow: 0 2px 8px rgba(139, 92, 246, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stop - Orange */
|
||||||
|
.btn-warning {
|
||||||
|
background: linear-gradient(135deg, #ea580c 0%, #f97316 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 2px 8px rgba(234, 88, 12, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .btn-warning {
|
||||||
|
background: linear-gradient(135deg, #f97316 0%, #fb923c 100%);
|
||||||
|
color: #1a1025;
|
||||||
|
box-shadow: 0 2px 8px rgba(249, 115, 22, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-warning:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(234, 88, 12, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cleanup - Plum */
|
||||||
|
.btn-danger {
|
||||||
|
background: linear-gradient(135deg, #9d174d 0%, #be185d 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 2px 8px rgba(157, 23, 77, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .btn-danger {
|
||||||
|
background: linear-gradient(135deg, #be185d 0%, #db2777 100%);
|
||||||
|
color: #f0e6ff;
|
||||||
|
box-shadow: 0 2px 8px rgba(190, 24, 93, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(157, 23, 77, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Refresh - Outline */
|
||||||
|
.btn-outline {
|
||||||
|
background: transparent;
|
||||||
|
border: 2px solid #7c3aed;
|
||||||
|
color: #7c3aed;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .btn-outline {
|
||||||
|
border-color: #a78bfa;
|
||||||
|
color: #a78bfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline:hover {
|
||||||
|
background: #7c3aed;
|
||||||
|
color: white;
|
||||||
|
border-color: #7c3aed;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .btn-outline:hover {
|
||||||
|
background: #a78bfa;
|
||||||
|
color: #1a1025;
|
||||||
|
border-color: #a78bfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Message */
|
||||||
|
.status-message {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats Cards */
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card .value {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter Bar */
|
||||||
|
.filter-bar {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrapper i {
|
||||||
|
position: absolute;
|
||||||
|
left: 16px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px 12px 44px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 24px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-refresh-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.table-container {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: auto;
|
||||||
|
min-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--header-bg);
|
||||||
|
color: var(--header-text);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
th i {
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
th.sorted-asc i,
|
||||||
|
th.sorted-desc i {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Job Filename */
|
||||||
|
.job-filename {
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Cascadia Code', 'Consolas', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-word;
|
||||||
|
white-space: normal;
|
||||||
|
max-width: 100%;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badge {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: inline-block;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-finished {
|
||||||
|
background: rgba(16, 185, 129, 0.15);
|
||||||
|
color: var(--accent-success);
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-started,
|
||||||
|
.badge-processing {
|
||||||
|
background: rgba(124, 58, 237, 0.15);
|
||||||
|
color: var(--accent-primary);
|
||||||
|
border: 1px solid rgba(124, 58, 237, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-queued {
|
||||||
|
background: rgba(234, 88, 12, 0.15);
|
||||||
|
color: var(--accent-warning);
|
||||||
|
border: 1px solid rgba(234, 88, 12, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-error {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
color: var(--accent-danger);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-pending {
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
color: var(--accent-secondary);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Date & Time */
|
||||||
|
.datetime {
|
||||||
|
font-family: 'Monaco', 'Menlo', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uid {
|
||||||
|
font-family: 'Monaco', 'Menlo', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state i {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
opacity: 0.5;
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading Spinner */
|
||||||
|
.spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 3px solid var(--border-color);
|
||||||
|
border-top-color: var(--accent-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-message {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
219
src/web.rs
Normal file
219
src/web.rs
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
use crate::config::Config;
|
||||||
|
use crate::processor::{cleanup_finished_jobs, fetch_all_jobs, process_spreadsheet};
|
||||||
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
http::StatusCode,
|
||||||
|
response::{Html, IntoResponse, Json},
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use tower_http::services::ServeDir;
|
||||||
|
use tower_http::trace::TraceLayer;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub config: Config,
|
||||||
|
pub last_generation: Arc<Mutex<Option<chrono::DateTime<chrono::Local>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct JobInfo {
|
||||||
|
pub uid: String,
|
||||||
|
pub outfile_name: String,
|
||||||
|
pub state: String,
|
||||||
|
pub created_at: Option<String>,
|
||||||
|
pub updated_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JobInfo {
|
||||||
|
fn from_nexrender_json(job: &serde_json::Value) -> Option<Self> {
|
||||||
|
let uid = job.get("uid")?.as_str()?.to_string();
|
||||||
|
let state = job.get("state")?.as_str()?.to_string();
|
||||||
|
|
||||||
|
// Извлекаем имя выходного файла из postrender actions
|
||||||
|
let outfile_name = job
|
||||||
|
.get("actions")
|
||||||
|
.and_then(|a| a.get("postrender"))
|
||||||
|
.and_then(|p| p.as_array())
|
||||||
|
.and_then(|arr| {
|
||||||
|
// Ищем действие copy (в нём финальный путь)
|
||||||
|
arr.iter()
|
||||||
|
.find_map(|action| {
|
||||||
|
// Проверяем, что это действие copy
|
||||||
|
action
|
||||||
|
.get("module")
|
||||||
|
.and_then(|m| m.as_str())
|
||||||
|
.filter(|&m| m == "@nexrender/action-copy")
|
||||||
|
.and_then(|_| {
|
||||||
|
// Извлекаем output из copy
|
||||||
|
action.get("output").and_then(|o| o.as_str())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
// Если copy не найдено, пробуем encode
|
||||||
|
.or_else(|| {
|
||||||
|
arr.iter()
|
||||||
|
.find_map(|action| action.get("output").and_then(|o| o.as_str()))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map(|path| {
|
||||||
|
// Извлекаем только имя файла из полного пути
|
||||||
|
std::path::Path::new(path)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or(path)
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
// Если не удалось извлечь, используем UID
|
||||||
|
format!("job_{}", uid)
|
||||||
|
});
|
||||||
|
|
||||||
|
Some(JobInfo {
|
||||||
|
uid,
|
||||||
|
outfile_name,
|
||||||
|
state,
|
||||||
|
created_at: job
|
||||||
|
.get("createdAt")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string()),
|
||||||
|
updated_at: job
|
||||||
|
.get("updatedAt")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// web.rs - функция run_web_server
|
||||||
|
pub async fn run_web_server(config: Config) -> anyhow::Result<()> {
|
||||||
|
let web_port = config.web_port; // Берём порт из конфига
|
||||||
|
let state = AppState {
|
||||||
|
config,
|
||||||
|
last_generation: Arc::new(Mutex::new(None)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/", get(index_page))
|
||||||
|
.route("/api/jobs", get(list_jobs))
|
||||||
|
.route("/api/generate", post(generate_jobs))
|
||||||
|
.route("/api/cleanup", post(cleanup_jobs))
|
||||||
|
.route("/api/status", get(get_status))
|
||||||
|
.route("/api/jobs/stop-all", post(stop_all_jobs))
|
||||||
|
.nest_service("/static", ServeDir::new("src/static"))
|
||||||
|
.nest_service("/assets", ServeDir::new("assets"))
|
||||||
|
.layer(TraceLayer::new_for_http())
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
let addr: SocketAddr = format!("0.0.0.0:{}", web_port).parse()?;
|
||||||
|
log::info!("Web server listening on http://{}", addr);
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
|
axum::serve(listener, app).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn index_page() -> Html<&'static str> {
|
||||||
|
Html(include_str!("static/index.html"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_jobs(State(state): State<AppState>) -> Result<Json<Vec<JobInfo>>, AppError> {
|
||||||
|
let jobs_json = fetch_all_jobs(&state.config.nexrender_api_url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
let jobs: Vec<JobInfo> = jobs_json
|
||||||
|
.iter()
|
||||||
|
.filter_map(JobInfo::from_nexrender_json)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(jobs))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn generate_jobs(State(state): State<AppState>) -> Result<impl IntoResponse, AppError> {
|
||||||
|
let mut last_gen = state.last_generation.lock().await;
|
||||||
|
if let Some(last) = *last_gen {
|
||||||
|
let elapsed = chrono::Local::now().signed_duration_since(last);
|
||||||
|
if elapsed.num_seconds() < 5 {
|
||||||
|
return Err(AppError(
|
||||||
|
StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
"Generation already in progress or too recent".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*last_gen = Some(chrono::Local::now());
|
||||||
|
drop(last_gen);
|
||||||
|
|
||||||
|
let config = state.config.clone();
|
||||||
|
let last_gen_clone = state.last_generation.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
match process_spreadsheet(&config).await {
|
||||||
|
Ok(submitted) => {
|
||||||
|
log::info!("Generation completed, {} jobs submitted", submitted.len());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Generation failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*last_gen_clone.lock().await = None;
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok((StatusCode::ACCEPTED, "Job generation started"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cleanup_jobs(State(state): State<AppState>) -> Result<impl IntoResponse, AppError> {
|
||||||
|
cleanup_finished_jobs(&state.config.nexrender_api_url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
Ok((StatusCode::OK, "Cleanup completed"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_status(State(state): State<AppState>) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
|
let last_gen = *state.last_generation.lock().await;
|
||||||
|
let status = serde_json::json!({
|
||||||
|
"last_generation": last_gen.map(|dt| dt.to_rfc3339()),
|
||||||
|
"nexrender_api": state.config.nexrender_api_url,
|
||||||
|
});
|
||||||
|
Ok(Json(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop_all_jobs(State(state): State<AppState>) -> Result<impl IntoResponse, AppError> {
|
||||||
|
use reqwest::Client;
|
||||||
|
|
||||||
|
let client = Client::new();
|
||||||
|
let api_url = &state.config.nexrender_api_url;
|
||||||
|
|
||||||
|
// Получаем все задания
|
||||||
|
let jobs = fetch_all_jobs(api_url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
let mut stopped = 0;
|
||||||
|
for job in jobs {
|
||||||
|
let state = job.get("state").and_then(|s| s.as_str()).unwrap_or("");
|
||||||
|
if state == "queued" || state == "started" || state == "processing" {
|
||||||
|
if let Some(uid) = job.get("uid").and_then(|u| u.as_str()) {
|
||||||
|
// Отправляем DELETE запрос для остановки задания
|
||||||
|
let _ = client.delete(&format!("{}/{}", api_url, uid)).send().await;
|
||||||
|
stopped += 1;
|
||||||
|
log::info!("Stopped job: {}", uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("Stopped {} active jobs", stopped);
|
||||||
|
Ok((StatusCode::OK, format!("Stopped {} jobs", stopped)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
struct AppError(StatusCode, String);
|
||||||
|
|
||||||
|
impl IntoResponse for AppError {
|
||||||
|
fn into_response(self) -> axum::response::Response {
|
||||||
|
(self.0, self.1).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user