Compare commits

..

No commits in common. "a7f52fe0eb5125dcfadf59e7afd13a9a6c44544b" and "10b17dc66bbbd0e343f09c197d1df7720c9dbac8" have entirely different histories.

268 changed files with 17623 additions and 24859 deletions

View File

@ -2,4 +2,32 @@
recorder-playground = "run -p recorder --example playground -- --environment development" recorder-playground = "run -p recorder --example playground -- --environment development"
[build] [build]
rustflags = ["-Zthreads=8", "-Zshare-generics=y"] rustflags = ["-Zthreads=8", "--cfg", "feature=\"testcontainers\""]
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-Zthreads=8", "-Clink-arg=-fuse-ld=lld", "-Zshare-generics=y"]
[target.x86_64-pc-windows-msvc]
linker = "rust-lld.exe"
rustflags = ["-Zthreads=8", "-Zshare-generics=n"]
# NOTE: you must install [Mach-O LLD Port](https://lld.llvm.org/MachO/index.html) on mac. you can easily do this by installing llvm which includes lld with the "brew" package manager:
# `brew install llvm`
#[target.x86_64-apple-darwin]
#rustflags = [
# "-Zthreads=8",
# "-C",
# "link-arg=-fuse-ld=/usr/local/opt/llvm/bin/ld64.lld",
# "-Zshare-generics=y",
#]
# NOTE: you must install [Mach-O LLD Port](https://lld.llvm.org/MachO/index.html) on mac. you can easily do this by installing llvm which includes lld with the "brew" package manager:
# `brew install llvm`
#[target.aarch64-apple-darwin]
#rustflags = [
# "-Zthreads=8",
# "-C",
# "link-arg=-fuse-ld=/opt/homebrew/opt/llvm/bin/ld64.lld",
# "-Zshare-generics=y",
#]

15
.vscode/settings.json vendored
View File

@ -28,18 +28,5 @@
"emmet.showExpandedAbbreviation": "never", "emmet.showExpandedAbbreviation": "never",
"prettier.enable": false, "prettier.enable": false,
"typescript.tsdk": "node_modules/typescript/lib", "typescript.tsdk": "node_modules/typescript/lib",
"rust-analyzer.cargo.features": [ "rust-analyzer.cargo.features": ["testcontainers"]
"testcontainers"
],
"sqltools.connections": [
{
"previewLimit": 50,
"server": "localhost",
"port": 5432,
"driver": "PostgreSQL",
"name": "konobangu-dev",
"database": "konobangu",
"username": "konobangu"
}
]
} }

421
Cargo.lock generated
View File

@ -27,41 +27,6 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array 0.14.7",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]] [[package]]
name = "ahash" name = "ahash"
version = "0.7.8" version = "0.7.8"
@ -193,56 +158,6 @@ version = "1.0.97"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f"
[[package]]
name = "apalis"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bf157b59923258974a886572a18fe47b401daeca43b44c719b73736d8788840"
dependencies = [
"apalis-core",
"futures",
"pin-project-lite",
"serde",
"thiserror 2.0.12",
"tower",
"tracing",
"tracing-futures",
]
[[package]]
name = "apalis-core"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fbbc8dc67f007145277cb5977c730c4fa7fb07244e83d69d1c5a43cb4d124fa"
dependencies = [
"futures",
"futures-timer",
"pin-project-lite",
"serde",
"serde_json",
"thiserror 2.0.12",
"tower",
"ulid",
]
[[package]]
name = "apalis-sql"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34757d9408f39656451c524ca10fe6331d59aaf25cda60bd70d677a4213efead"
dependencies = [
"apalis-core",
"async-stream",
"chrono",
"futures",
"futures-lite",
"log",
"serde",
"serde_json",
"sqlx",
"thiserror 2.0.12",
]
[[package]] [[package]]
name = "arc-swap" name = "arc-swap"
version = "1.7.1" version = "1.7.1"
@ -335,7 +250,6 @@ dependencies = [
"async-stream", "async-stream",
"async-trait", "async-trait",
"base64 0.22.1", "base64 0.22.1",
"bigdecimal",
"bytes", "bytes",
"chrono", "chrono",
"fast_chemail", "fast_chemail",
@ -359,7 +273,6 @@ dependencies = [
"static_assertions_next", "static_assertions_next",
"tempfile", "tempfile",
"thiserror 1.0.69", "thiserror 1.0.69",
"time",
] ]
[[package]] [[package]]
@ -733,15 +646,6 @@ dependencies = [
"wyz", "wyz",
] ]
[[package]]
name = "block-buffer"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4"
dependencies = [
"generic-array 0.14.7",
]
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@ -911,7 +815,7 @@ version = "13.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c5063741c7b2e260bbede781cf4679632dd90e2718e99f7715e46824b65670b" checksum = "5c5063741c7b2e260bbede781cf4679632dd90e2718e99f7715e46824b65670b"
dependencies = [ dependencies = [
"digest 0.10.7", "digest",
"either", "either",
"futures", "futures",
"hex 0.4.3", "hex 0.4.3",
@ -923,7 +827,7 @@ dependencies = [
"serde_derive", "serde_derive",
"serde_json", "serde_json",
"sha1", "sha1",
"sha2 0.10.8", "sha2",
"ssri", "ssri",
"tempfile", "tempfile",
"thiserror 1.0.69", "thiserror 1.0.69",
@ -965,30 +869,6 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "chacha20"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "chacha20poly1305"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead",
"chacha20",
"cipher",
"poly1305",
"zeroize",
]
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.40" version = "0.4.40"
@ -1026,17 +906,6 @@ dependencies = [
"phf_codegen", "phf_codegen",
] ]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
"zeroize",
]
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.5.35" version = "4.5.35"
@ -1077,22 +946,6 @@ version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"
[[package]]
name = "cocoon"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24bf1b609cc3fcf6785a2305e450e3dae64cc0f28854ae0b83a6487a8eeaa64f"
dependencies = [
"aes-gcm",
"chacha20poly1305",
"hmac 0.11.0",
"pbkdf2",
"rand 0.8.5",
"sha2 0.9.9",
"thiserror 1.0.69",
"zeroize",
]
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.3" version = "1.0.3"
@ -1353,16 +1206,6 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "crypto-mac"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25fab6889090c8133f3deb8f73ba3c65a7f456f66436fc012a1b1e272b1e103e"
dependencies = [
"generic-array 0.14.7",
"subtle",
]
[[package]] [[package]]
name = "cssparser" name = "cssparser"
version = "0.33.0" version = "0.33.0"
@ -1424,15 +1267,6 @@ version = "0.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f211af61d8efdd104f96e57adf5e426ba1bc3ed7a4ead616e15e5881fd79c4d" checksum = "4f211af61d8efdd104f96e57adf5e426ba1bc3ed7a4ead616e15e5881fd79c4d"
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]] [[package]]
name = "curve25519-dalek" name = "curve25519-dalek"
version = "4.1.3" version = "4.1.3"
@ -1442,7 +1276,7 @@ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures",
"curve25519-dalek-derive", "curve25519-dalek-derive",
"digest 0.10.7", "digest",
"fiat-crypto", "fiat-crypto",
"rustc_version", "rustc_version",
"subtle", "subtle",
@ -1607,22 +1441,13 @@ version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc55fe0d1f6c107595572ec8b107c0999bb1a2e0b75e37429a4fb0d6474a0e7d" checksum = "dc55fe0d1f6c107595572ec8b107c0999bb1a2e0b75e37429a4fb0d6474a0e7d"
[[package]]
name = "digest"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066"
dependencies = [
"generic-array 0.14.7",
]
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer 0.10.4", "block-buffer",
"const-oid", "const-oid",
"crypto-common", "crypto-common",
"subtle", "subtle",
@ -1709,6 +1534,7 @@ dependencies = [
"async-trait", "async-trait",
"bytes", "bytes",
"chrono", "chrono",
"dashmap 6.1.0",
"fetch", "fetch",
"futures", "futures",
"itertools 0.14.0", "itertools 0.14.0",
@ -1720,6 +1546,7 @@ dependencies = [
"reqwest", "reqwest",
"serde", "serde",
"serde-value", "serde-value",
"serde_json",
"snafu", "snafu",
"testcontainers", "testcontainers",
"testcontainers-ext", "testcontainers-ext",
@ -1775,7 +1602,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [ dependencies = [
"der", "der",
"digest 0.10.7", "digest",
"elliptic-curve", "elliptic-curve",
"rfc6979", "rfc6979",
"signature", "signature",
@ -1801,7 +1628,7 @@ dependencies = [
"curve25519-dalek", "curve25519-dalek",
"ed25519", "ed25519",
"serde", "serde",
"sha2 0.10.8", "sha2",
"subtle", "subtle",
"zeroize", "zeroize",
] ]
@ -1829,7 +1656,7 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [ dependencies = [
"base16ct", "base16ct",
"crypto-bigint", "crypto-bigint",
"digest 0.10.7", "digest",
"ff", "ff",
"generic-array 0.14.7", "generic-array 0.14.7",
"group", "group",
@ -1939,6 +1766,7 @@ dependencies = [
"axum", "axum",
"axum-extra", "axum-extra",
"bytes", "bytes",
"cookie",
"fastrand", "fastrand",
"http-cache", "http-cache",
"http-cache-reqwest", "http-cache-reqwest",
@ -1950,13 +1778,11 @@ dependencies = [
"reqwest-middleware", "reqwest-middleware",
"reqwest-retry", "reqwest-retry",
"reqwest-tracing", "reqwest-tracing",
"reqwest_cookie_store",
"serde", "serde",
"serde_json", "serde_json",
"serde_with", "serde_with",
"snafu", "snafu",
"url", "url",
"util",
] ]
[[package]] [[package]]
@ -2144,19 +1970,6 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-lite"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5edaec856126859abb19ed65f39e90fea3a9574b9707f13539acf4abf7eb532"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]] [[package]]
name = "futures-macro" name = "futures-macro"
version = "0.3.31" version = "0.3.31"
@ -2295,16 +2108,6 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]] [[package]]
name = "gimli" name = "gimli"
version = "0.31.1" version = "0.31.1"
@ -2370,7 +2173,7 @@ dependencies = [
"parking_lot 0.12.3", "parking_lot 0.12.3",
"portable-atomic", "portable-atomic",
"quanta", "quanta",
"rand 0.9.1", "rand 0.9.0",
"smallvec", "smallvec",
"spinning_top", "spinning_top",
"web-time", "web-time",
@ -2509,17 +2312,7 @@ version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [ dependencies = [
"hmac 0.12.1", "hmac",
]
[[package]]
name = "hmac"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a2a2320eb7ec0ebe8da8f744d7812d9fc4cb4d09344ac01898dbcb6a20ae69b"
dependencies = [
"crypto-mac",
"digest 0.9.0",
] ]
[[package]] [[package]]
@ -2528,7 +2321,7 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [ dependencies = [
"digest 0.10.7", "digest",
] ]
[[package]] [[package]]
@ -3037,15 +2830,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array 0.14.7",
]
[[package]] [[package]]
name = "insta" name = "insta"
version = "1.42.2" version = "1.42.2"
@ -3610,9 +3394,9 @@ dependencies = [
[[package]] [[package]]
name = "lightningcss" name = "lightningcss"
version = "1.0.0-alpha.66" version = "1.0.0-alpha.65"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a73ffa17de66534e4b527232f44aa0a89fad22c4f4e0735f9be35494f058e54" checksum = "c84f971730745f4aaac013b6cf4328baf1548efc973c0d95cfd843a3c1ca07af"
dependencies = [ dependencies = [
"ahash 0.8.11", "ahash 0.8.11",
"bitflags 2.9.0", "bitflags 2.9.0",
@ -3787,7 +3571,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"digest 0.10.7", "digest",
] ]
[[package]] [[package]]
@ -3900,7 +3684,7 @@ dependencies = [
"hyper", "hyper",
"hyper-util", "hyper-util",
"log", "log",
"rand 0.9.1", "rand 0.9.0",
"regex", "regex",
"serde_json", "serde_json",
"serde_urlencoded", "serde_urlencoded",
@ -4171,7 +3955,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"serde_path_to_error", "serde_path_to_error",
"sha2 0.10.8", "sha2",
"thiserror 1.0.69", "thiserror 1.0.69",
"url", "url",
] ]
@ -4191,12 +3975,6 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]] [[package]]
name = "opendal" name = "opendal"
version = "0.53.0" version = "0.53.0"
@ -4233,7 +4011,7 @@ dependencies = [
"chrono", "chrono",
"dyn-clone", "dyn-clone",
"ed25519-dalek", "ed25519-dalek",
"hmac 0.12.1", "hmac",
"http", "http",
"itertools 0.10.5", "itertools 0.10.5",
"log", "log",
@ -4248,7 +4026,7 @@ dependencies = [
"serde_path_to_error", "serde_path_to_error",
"serde_plain", "serde_plain",
"serde_with", "serde_with",
"sha2 0.10.8", "sha2",
"subtle", "subtle",
"thiserror 1.0.69", "thiserror 1.0.69",
"url", "url",
@ -4367,7 +4145,7 @@ dependencies = [
"ecdsa", "ecdsa",
"elliptic-curve", "elliptic-curve",
"primeorder", "primeorder",
"sha2 0.10.8", "sha2",
] ]
[[package]] [[package]]
@ -4379,14 +4157,14 @@ dependencies = [
"ecdsa", "ecdsa",
"elliptic-curve", "elliptic-curve",
"primeorder", "primeorder",
"sha2 0.10.8", "sha2",
] ]
[[package]] [[package]]
name = "parcel_selectors" name = "parcel_selectors"
version = "0.28.2" version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54fd03f1ad26cb6b3ec1b7414fa78a3bd639e7dbb421b1a60513c96ce886a196" checksum = "dccbc6fb560df303a44e511618256029410efbc87779018f751ef12c488271fe"
dependencies = [ dependencies = [
"bitflags 2.9.0", "bitflags 2.9.0",
"cssparser 0.33.0", "cssparser 0.33.0",
@ -4512,17 +4290,6 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pbkdf2"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05894bce6a1ba4be299d0c5f29563e08af2bc18bb7d48313113bed71e904739"
dependencies = [
"crypto-mac",
"hmac 0.11.0",
"sha2 0.9.9",
]
[[package]] [[package]]
name = "pear" name = "pear"
version = "0.2.9" version = "0.2.9"
@ -4613,7 +4380,7 @@ checksum = "7f9f832470494906d1fca5329f8ab5791cc60beb230c74815dff541cbd2b5ca0"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"pest", "pest",
"sha2 0.10.8", "sha2",
] ]
[[package]] [[package]]
@ -4736,29 +4503,6 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]] [[package]]
name = "portable-atomic" name = "portable-atomic"
version = "1.11.0" version = "1.11.0"
@ -4968,7 +4712,7 @@ checksum = "b820744eb4dc9b57a3398183639c511b5a26d2ed702cedd3febaa1393caa22cc"
dependencies = [ dependencies = [
"bytes", "bytes",
"getrandom 0.3.2", "getrandom 0.3.2",
"rand 0.9.1", "rand 0.9.0",
"ring", "ring",
"rustc-hash 2.1.1", "rustc-hash 2.1.1",
"rustls", "rustls",
@ -5041,12 +4785,13 @@ dependencies = [
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.9.1" version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94"
dependencies = [ dependencies = [
"rand_chacha 0.9.0", "rand_chacha 0.9.0",
"rand_core 0.9.3", "rand_core 0.9.3",
"zerocopy 0.8.24",
] ]
[[package]] [[package]]
@ -5120,8 +4865,6 @@ dependencies = [
name = "recorder" name = "recorder"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"apalis",
"apalis-sql",
"async-graphql", "async-graphql",
"async-graphql-axum", "async-graphql-axum",
"async-stream", "async-stream",
@ -5132,7 +4875,6 @@ dependencies = [
"bytes", "bytes",
"chrono", "chrono",
"clap", "clap",
"cocoon",
"ctor", "ctor",
"dotenv", "dotenv",
"downloader", "downloader",
@ -5156,9 +4898,7 @@ dependencies = [
"opendal", "opendal",
"openidconnect", "openidconnect",
"quirks_path", "quirks_path",
"rand 0.9.1",
"regex", "regex",
"reqwest_cookie_store",
"rss", "rss",
"rstest", "rstest",
"scraper", "scraper",
@ -5172,6 +4912,7 @@ dependencies = [
"serde_yaml", "serde_yaml",
"serial_test", "serial_test",
"snafu", "snafu",
"string-interner",
"tera", "tera",
"testcontainers", "testcontainers",
"testcontainers-ext", "testcontainers-ext",
@ -5406,20 +5147,6 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "reqwest_cookie_store"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0b36498c7452f11b1833900f31fbb01fc46be20992a50269c88cf59d79f54e9"
dependencies = [
"bytes",
"cookie_store",
"reqwest",
"serde",
"serde_derive",
"url",
]
[[package]] [[package]]
name = "retry-policies" name = "retry-policies"
version = "0.4.0" version = "0.4.0"
@ -5435,7 +5162,7 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
dependencies = [ dependencies = [
"hmac 0.12.1", "hmac",
"subtle", "subtle",
] ]
@ -5498,7 +5225,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b"
dependencies = [ dependencies = [
"const-oid", "const-oid",
"digest 0.10.7", "digest",
"num-bigint-dig", "num-bigint-dig",
"num-integer", "num-integer",
"num-traits", "num-traits",
@ -5905,7 +5632,8 @@ checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]] [[package]]
name = "seaography" name = "seaography"
version = "1.1.4" version = "1.1.4"
source = "git+https://github.com/dumtruck/seaography.git?rev=10ba248#10ba2487fb356a0385c598290668a01e0ef21734" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f5e0455935e4f31eb64ce606d9963715efd4c1856edb129619126f6b5372fcf"
dependencies = [ dependencies = [
"async-graphql", "async-graphql",
"fnv", "fnv",
@ -6179,7 +5907,7 @@ checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures",
"digest 0.10.7", "digest",
] ]
[[package]] [[package]]
@ -6190,20 +5918,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures",
"digest 0.10.7", "digest",
]
[[package]]
name = "sha2"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800"
dependencies = [
"block-buffer 0.9.0",
"cfg-if",
"cpufeatures",
"digest 0.9.0",
"opaque-debug",
] ]
[[package]] [[package]]
@ -6214,7 +5929,7 @@ checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures",
"digest 0.10.7", "digest",
] ]
[[package]] [[package]]
@ -6247,7 +5962,7 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [ dependencies = [
"digest 0.10.7", "digest",
"rand_core 0.6.4", "rand_core 0.6.4",
] ]
@ -6437,7 +6152,7 @@ dependencies = [
"rustls-pemfile", "rustls-pemfile",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.10.8", "sha2",
"smallvec", "smallvec",
"thiserror 2.0.12", "thiserror 2.0.12",
"time", "time",
@ -6477,7 +6192,7 @@ dependencies = [
"quote", "quote",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.10.8", "sha2",
"sqlx-core", "sqlx-core",
"sqlx-mysql", "sqlx-mysql",
"sqlx-postgres", "sqlx-postgres",
@ -6502,7 +6217,7 @@ dependencies = [
"bytes", "bytes",
"chrono", "chrono",
"crc", "crc",
"digest 0.10.7", "digest",
"dotenvy", "dotenvy",
"either", "either",
"futures-channel", "futures-channel",
@ -6512,7 +6227,7 @@ dependencies = [
"generic-array 0.14.7", "generic-array 0.14.7",
"hex 0.4.3", "hex 0.4.3",
"hkdf", "hkdf",
"hmac 0.12.1", "hmac",
"itoa", "itoa",
"log", "log",
"md-5", "md-5",
@ -6524,7 +6239,7 @@ dependencies = [
"rust_decimal", "rust_decimal",
"serde", "serde",
"sha1", "sha1",
"sha2 0.10.8", "sha2",
"smallvec", "smallvec",
"sqlx-core", "sqlx-core",
"stringprep", "stringprep",
@ -6555,7 +6270,7 @@ dependencies = [
"futures-util", "futures-util",
"hex 0.4.3", "hex 0.4.3",
"hkdf", "hkdf",
"hmac 0.12.1", "hmac",
"home", "home",
"itoa", "itoa",
"log", "log",
@ -6567,7 +6282,7 @@ dependencies = [
"rust_decimal", "rust_decimal",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.10.8", "sha2",
"smallvec", "smallvec",
"sqlx-core", "sqlx-core",
"stringprep", "stringprep",
@ -6611,12 +6326,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7a2b3c2bc9693bcb40870c4e9b5bf0d79f9cb46273321bf855ec513e919082" checksum = "da7a2b3c2bc9693bcb40870c4e9b5bf0d79f9cb46273321bf855ec513e919082"
dependencies = [ dependencies = [
"base64 0.21.7", "base64 0.21.7",
"digest 0.10.7", "digest",
"hex 0.4.3", "hex 0.4.3",
"miette", "miette",
"serde", "serde",
"sha-1", "sha-1",
"sha2 0.10.8", "sha2",
"thiserror 1.0.69", "thiserror 1.0.69",
"xxhash-rust", "xxhash-rust",
] ]
@ -6639,6 +6354,16 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7beae5182595e9a8b683fa98c4317f956c9a2dec3b9716990d20023cc60c766" checksum = "d7beae5182595e9a8b683fa98c4317f956c9a2dec3b9716990d20023cc60c766"
[[package]]
name = "string-interner"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23de088478b31c349c9ba67816fa55d9355232d63c3afea8bf513e31f0f1d2c0"
dependencies = [
"hashbrown 0.15.2",
"serde",
]
[[package]] [[package]]
name = "string_cache" name = "string_cache"
version = "0.8.9" version = "0.8.9"
@ -7180,7 +6905,6 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-util",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing", "tracing",
@ -7271,15 +6995,6 @@ dependencies = [
"valuable", "valuable",
] ]
[[package]]
name = "tracing-futures"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2"
dependencies = [
"tracing",
]
[[package]] [[package]]
name = "tracing-log" name = "tracing-log"
version = "0.2.0" version = "0.2.0"
@ -7339,7 +7054,7 @@ dependencies = [
"http", "http",
"httparse", "httparse",
"log", "log",
"rand 0.9.1", "rand 0.9.0",
"sha1", "sha1",
"thiserror 2.0.12", "thiserror 2.0.12",
"utf-8", "utf-8",
@ -7397,16 +7112,6 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "ulid"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe"
dependencies = [
"rand 0.9.1",
"web-time",
]
[[package]] [[package]]
name = "uncased" name = "uncased"
version = "0.9.10" version = "0.9.10"
@ -7511,16 +7216,6 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]] [[package]]
name = "unsafe-libyaml" name = "unsafe-libyaml"
version = "0.2.11" version = "0.2.11"

View File

@ -11,6 +11,7 @@ resolver = "2"
[workspace.dependencies] [workspace.dependencies]
moka = "0.12" moka = "0.12"
futures = "0.3" futures = "0.3"
futures-util = "0.3"
quirks_path = "0.1" quirks_path = "0.1"
snafu = { version = "0.8", features = ["futures"] } snafu = { version = "0.8", features = ["futures"] }
testcontainers = { version = "0.23.3" } testcontainers = { version = "0.23.3" }
@ -45,7 +46,7 @@ testing-torrents = { path = "./packages/testing-torrents" }
util = { path = "./packages/util" } util = { path = "./packages/util" }
fetch = { path = "./packages/fetch" } fetch = { path = "./packages/fetch" }
downloader = { path = "./packages/downloader" } downloader = { path = "./packages/downloader" }
recorder = { path = "./apps/recorder" }
[patch.crates-io] [patch.crates-io]
jwt-authorizer = { git = "https://github.com/blablacio/jwt-authorizer.git", rev = "e956774" } jwt-authorizer = { git = "https://github.com/blablacio/jwt-authorizer.git", rev = "e956774" }
seaography = { git = "https://github.com/dumtruck/seaography.git", rev = "10ba248" }

View File

@ -5,7 +5,6 @@
} }
``` ```
#^https://konobangu.com/api*** statusCode://500 ^https://konobangu.com/api*** reqHeaders://{x-forwarded.json} http://127.0.0.1:5001/api$1
^https://konobangu.com/api*** reqHeaders://{x-forwarded.json} http://127.0.0.1:5001/api$1
^https://konobangu.com/*** reqHeaders://{x-forwarded.json} http://127.0.0.1:5000/$1 excludeFilter://^https://konobangu.com/api*** ^https://konobangu.com/*** reqHeaders://{x-forwarded.json} http://127.0.0.1:5000/$1 excludeFilter://^https://konobangu.com/api***
^wss://konobangu.com/*** reqHeaders://{x-forwarded.json} ws://127.0.0.1:5000/$1 excludeFilter://^wss://konobangu.com/api ^wss://konobangu.com/*** reqHeaders://{x-forwarded.json} ws://127.0.0.1:5000/$1 excludeFilter://^wss://konobangu.com/api

View File

@ -1 +1 @@
{"filesOrder":["konobangu"],"selectedList":["konobangu"],"disabledDefalutRules":true,"defalutRules":""} {"filesOrder":["konobangu"],"selectedList":["konobangu"],"disabledDefalutRules":true}

View File

@ -1,13 +1,8 @@
HOST="konobangu.com"
DATABASE_URL = "postgres://konobangu:konobangu@localhost:5432/konobangu"
STORAGE_DATA_DIR = "./data"
AUTH_TYPE = "basic" # or oidc AUTH_TYPE = "basic" # or oidc
BASIC_USER = "konobangu" BASIC_USER = "konobangu"
BASIC_PASSWORD = "konobangu" BASIC_PASSWORD = "konobangu"
# OIDC_ISSUER="https://auth.logto.io/oidc" # OIDC_ISSUER="https://auth.logto.io/oidc"
# OIDC_AUDIENCE = "https://konobangu.com/api" # OIDC_API_AUDIENCE = "https://konobangu.com/api"
# OIDC_CLIENT_ID = "client_id" # OIDC_CLIENT_ID = "client_id"
# OIDC_CLIENT_SECRET = "client_secret" # optional # OIDC_CLIENT_SECRET = "client_secret" # optional
# OIDC_EXTRA_SCOPES = "read:konobangu write:konobangu" # OIDC_EXTRA_SCOPES = "read:konobangu write:konobangu"
# OIDC_EXTRA_CLAIM_KEY = ""
# OIDC_EXTRA_CLAIM_VALUE = ""

View File

@ -25,5 +25,4 @@ Cargo.lock
# Dist # Dist
node_modules node_modules
dist/ dist/
temp/* temp/
!temp/.gitkeep

View File

@ -19,8 +19,6 @@ testcontainers = [
"dep:testcontainers", "dep:testcontainers",
"dep:testcontainers-modules", "dep:testcontainers-modules",
"dep:testcontainers-ext", "dep:testcontainers-ext",
"downloader/testcontainers",
"testcontainers-modules/postgres",
] ]
[dependencies] [dependencies]
@ -63,7 +61,7 @@ sea-orm-migration = { version = "1.1", features = ["runtime-tokio-rustls"] }
rss = "2" rss = "2"
fancy-regex = "0.14" fancy-regex = "0.14"
maplit = "1.0.2" maplit = "1.0.2"
lightningcss = "1.0.0-alpha.66" lightningcss = "1.0.0-alpha.65"
html-escape = "0.2.13" html-escape = "0.2.13"
opendal = { version = "0.53", features = ["default", "services-fs"] } opendal = { version = "0.53", features = ["default", "services-fs"] }
zune-image = "0.4.15" zune-image = "0.4.15"
@ -74,15 +72,7 @@ jwt-authorizer = "0.15.0"
log = "0.4" log = "0.4"
async-graphql = { version = "7", features = [] } async-graphql = { version = "7", features = [] }
async-graphql-axum = "7" async-graphql-axum = "7"
seaography = { version = "1.1", features = [ seaography = { version = "1.1" }
"with-json",
"with-chrono",
"with-time",
"with-uuid",
"with-decimal",
"with-bigdecimal",
"with-postgres-array",
] }
base64 = "0.22.1" base64 = "0.22.1"
tower = "0.5.2" tower = "0.5.2"
tower-http = { version = "0.6", features = [ tower-http = { version = "0.6", features = [
@ -106,15 +96,11 @@ clap = "4.5.31"
ipnetwork = "0.21.1" ipnetwork = "0.21.1"
typed-builder = "0.21.0" typed-builder = "0.21.0"
serde_yaml = "0.9.34" serde_yaml = "0.9.34"
apalis = { version = "0.7", features = ["limit", "tracing", "catch-panic"] }
apalis-sql = { version = "0.7", features = ["postgres"] }
cocoon = { version = "0.4.3", features = ["getrandom", "thiserror"] }
rand = "0.9.1"
reqwest_cookie_store = "0.8.0"
downloader = { workspace = true } downloader = { workspace = true }
util = { workspace = true } util = { workspace = true }
fetch = { workspace = true } fetch = { workspace = true }
string-interner = "0.19.0"
[dev-dependencies] [dev-dependencies]
serial_test = "3" serial_test = "3"

View File

@ -1,3 +1,5 @@
use std::sync::Arc;
use clap::{Parser, command}; use clap::{Parser, command};
use super::{AppContext, core::App, env::Environment}; use super::{AppContext, core::App, env::Environment};
@ -81,8 +83,9 @@ impl AppBuilder {
) )
.await?; .await?;
let app_context = let app_context = Arc::new(
AppContext::new(self.environment.clone(), config, self.working_dir.clone()).await?; AppContext::new(self.environment.clone(), config, self.working_dir.clone()).await?,
);
Ok(App { Ok(App {
context: app_context, context: app_context,

View File

@ -16,7 +16,3 @@ depth_limit = inf
complexity_limit = inf complexity_limit = inf
[cache] [cache]
[crypto]
[task]

View File

@ -9,9 +9,9 @@ use serde::{Deserialize, Serialize};
use super::env::Environment; use super::env::Environment;
use crate::{ use crate::{
auth::AuthConfig, cache::CacheConfig, crypto::CryptoConfig, database::DatabaseConfig, auth::AuthConfig, cache::CacheConfig, database::DatabaseConfig, errors::RecorderResult,
errors::RecorderResult, extract::mikan::MikanConfig, graphql::GraphQLConfig, extract::mikan::MikanConfig, graphql::GraphQLConfig, logger::LoggerConfig,
logger::LoggerConfig, storage::StorageConfig, tasks::TaskConfig, web::WebServerConfig, storage::StorageConfig, web::WebServerConfig,
}; };
const DEFAULT_CONFIG_MIXIN: &str = include_str!("./default_mixin.toml"); const DEFAULT_CONFIG_MIXIN: &str = include_str!("./default_mixin.toml");
@ -24,11 +24,9 @@ pub struct AppConfig {
pub auth: AuthConfig, pub auth: AuthConfig,
pub storage: StorageConfig, pub storage: StorageConfig,
pub mikan: MikanConfig, pub mikan: MikanConfig,
pub crypto: CryptoConfig,
pub graphql: GraphQLConfig, pub graphql: GraphQLConfig,
pub logger: LoggerConfig, pub logger: LoggerConfig,
pub database: DatabaseConfig, pub database: DatabaseConfig,
pub tasks: TaskConfig,
} }
impl AppConfig { impl AppConfig {
@ -142,7 +140,7 @@ impl AppConfig {
.flat_map(|ps| { .flat_map(|ps| {
allowed_extensions allowed_extensions
.iter() .iter()
.map(move |ext| (format!("{convention_prefix}{ps}{ext}"), ext)) .map(move |ext| (format!("{}{}{}", convention_prefix, ps, ext), ext))
}) })
.collect_vec(); .collect_vec();

View File

@ -1,22 +1,11 @@
use std::{fmt::Debug, sync::Arc};
use tokio::sync::OnceCell;
use super::{Environment, config::AppConfig}; use super::{Environment, config::AppConfig};
use crate::{ use crate::{
auth::AuthService, auth::AuthService, cache::CacheService, database::DatabaseService, errors::RecorderResult,
cache::CacheService, extract::mikan::MikanClient, graphql::GraphQLService, logger::LoggerService,
crypto::CryptoService, storage::StorageService,
database::DatabaseService,
errors::RecorderResult,
extract::mikan::MikanClient,
graphql::GraphQLService,
logger::LoggerService,
storage::{StorageService, StorageServiceTrait},
tasks::TaskService,
}; };
pub trait AppContextTrait: Send + Sync + Debug { pub trait AppContextTrait: Send + Sync {
fn logger(&self) -> &LoggerService; fn logger(&self) -> &LoggerService;
fn db(&self) -> &DatabaseService; fn db(&self) -> &DatabaseService;
fn config(&self) -> &AppConfig; fn config(&self) -> &AppConfig;
@ -24,11 +13,9 @@ pub trait AppContextTrait: Send + Sync + Debug {
fn mikan(&self) -> &MikanClient; fn mikan(&self) -> &MikanClient;
fn auth(&self) -> &AuthService; fn auth(&self) -> &AuthService;
fn graphql(&self) -> &GraphQLService; fn graphql(&self) -> &GraphQLService;
fn storage(&self) -> &dyn StorageServiceTrait; fn storage(&self) -> &StorageService;
fn working_dir(&self) -> &String; fn working_dir(&self) -> &String;
fn environment(&self) -> &Environment; fn environment(&self) -> &Environment;
fn crypto(&self) -> &CryptoService;
fn task(&self) -> &TaskService;
} }
pub struct AppContext { pub struct AppContext {
@ -40,10 +27,8 @@ pub struct AppContext {
auth: AuthService, auth: AuthService,
graphql: GraphQLService, graphql: GraphQLService,
storage: StorageService, storage: StorageService,
crypto: CryptoService,
working_dir: String, working_dir: String,
environment: Environment, environment: Environment,
task: OnceCell<TaskService>,
} }
impl AppContext { impl AppContext {
@ -51,7 +36,7 @@ impl AppContext {
environment: Environment, environment: Environment,
config: AppConfig, config: AppConfig,
working_dir: impl ToString, working_dir: impl ToString,
) -> RecorderResult<Arc<Self>> { ) -> RecorderResult<Self> {
let config_cloned = config.clone(); let config_cloned = config.clone();
let logger = LoggerService::from_config(config.logger).await?; let logger = LoggerService::from_config(config.logger).await?;
@ -60,10 +45,9 @@ impl AppContext {
let storage = StorageService::from_config(config.storage).await?; let storage = StorageService::from_config(config.storage).await?;
let auth = AuthService::from_conf(config.auth).await?; let auth = AuthService::from_conf(config.auth).await?;
let mikan = MikanClient::from_config(config.mikan).await?; let mikan = MikanClient::from_config(config.mikan).await?;
let crypto = CryptoService::from_config(config.crypto).await?;
let graphql = GraphQLService::from_config_and_database(config.graphql, db.clone()).await?; let graphql = GraphQLService::from_config_and_database(config.graphql, db.clone()).await?;
let ctx = Arc::new(AppContext { Ok(AppContext {
config: config_cloned, config: config_cloned,
environment, environment,
logger, logger,
@ -74,26 +58,9 @@ impl AppContext {
mikan, mikan,
working_dir: working_dir.to_string(), working_dir: working_dir.to_string(),
graphql, graphql,
crypto, })
task: OnceCell::new(),
});
ctx.task
.get_or_try_init(async || {
TaskService::from_config_and_ctx(config.tasks, ctx.clone()).await
})
.await?;
Ok(ctx)
} }
} }
impl Debug for AppContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "AppContext")
}
}
impl AppContextTrait for AppContext { impl AppContextTrait for AppContext {
fn logger(&self) -> &LoggerService { fn logger(&self) -> &LoggerService {
&self.logger &self.logger
@ -116,7 +83,7 @@ impl AppContextTrait for AppContext {
fn graphql(&self) -> &GraphQLService { fn graphql(&self) -> &GraphQLService {
&self.graphql &self.graphql
} }
fn storage(&self) -> &dyn StorageServiceTrait { fn storage(&self) -> &StorageService {
&self.storage &self.storage
} }
fn working_dir(&self) -> &String { fn working_dir(&self) -> &String {
@ -125,10 +92,4 @@ impl AppContextTrait for AppContext {
fn environment(&self) -> &Environment { fn environment(&self) -> &Environment {
&self.environment &self.environment
} }
fn crypto(&self) -> &CryptoService {
&self.crypto
}
fn task(&self) -> &TaskService {
self.task.get().expect("task should be set")
}
} }

View File

@ -78,25 +78,12 @@ impl App {
.await; .await;
}; };
#[cfg(all(unix, debug_assertions))]
let quit = async {
signal::unix::signal(signal::unix::SignalKind::quit())
.expect("Failed to install SIGQUIT handler")
.recv()
.await;
println!("Received SIGQUIT");
};
#[cfg(not(unix))] #[cfg(not(unix))]
let terminate = std::future::pending::<()>(); let terminate = std::future::pending::<()>();
#[cfg(all(not(unix), debug_assertions))]
let quit = std::future::pending::<()>();
tokio::select! { tokio::select! {
() = ctrl_c => {}, () = ctrl_c => {},
() = terminate => {}, () = terminate => {},
() = quit => {},
} }
} }
} }

View File

@ -71,16 +71,18 @@ impl AuthServiceTrait for BasicAuthService {
user: found_user, user: found_user,
password: found_password, password: found_password,
}) = AuthBasic::decode_request_parts(request) }) = AuthBasic::decode_request_parts(request)
&& self.config.user == found_user
&& self.config.password == found_password.unwrap_or_default()
{ {
let subscriber_auth = crate::models::auth::Model::find_by_pid(ctx, SEED_SUBSCRIBER) if self.config.user == found_user
.await && self.config.password == found_password.unwrap_or_default()
.map_err(|_| AuthError::FindAuthRecordError)?; {
return Ok(AuthUserInfo { let subscriber_auth = crate::models::auth::Model::find_by_pid(ctx, SEED_SUBSCRIBER)
subscriber_auth, .await
auth_type: AuthType::Basic, .map_err(|_| AuthError::FindAuthRecordError)?;
}); return Ok(AuthUserInfo {
subscriber_auth,
auth_type: AuthType::Basic,
});
}
} }
Err(AuthError::BasicInvalidCredentials) Err(AuthError::BasicInvalidCredentials)
} }

View File

@ -9,7 +9,7 @@ use axum::{
use crate::{app::AppContextTrait, auth::AuthServiceTrait}; use crate::{app::AppContextTrait, auth::AuthServiceTrait};
pub async fn auth_middleware( pub async fn header_www_authenticate_middleware(
State(ctx): State<Arc<dyn AppContextTrait>>, State(ctx): State<Arc<dyn AppContextTrait>>,
request: Request, request: Request,
next: Next, next: Next,

View File

@ -7,5 +7,5 @@ pub mod service;
pub use config::{AuthConfig, BasicAuthConfig, OidcAuthConfig}; pub use config::{AuthConfig, BasicAuthConfig, OidcAuthConfig};
pub use errors::AuthError; pub use errors::AuthError;
pub use middleware::auth_middleware; pub use middleware::header_www_authenticate_middleware;
pub use service::{AuthService, AuthServiceTrait, AuthUserInfo}; pub use service::{AuthService, AuthServiceTrait, AuthUserInfo};

View File

@ -297,10 +297,10 @@ impl OidcAuthService {
id_token.signing_key(id_token_verifier)?, id_token.signing_key(id_token_verifier)?,
)?; )?;
if let Some(expected_access_token_hash) = claims.access_token_hash() if let Some(expected_access_token_hash) = claims.access_token_hash() {
&& actual_access_token_hash != *expected_access_token_hash if actual_access_token_hash != *expected_access_token_hash {
{ return Err(AuthError::OidcInvalidAccessTokenError);
return Err(AuthError::OidcInvalidAccessTokenError); }
} }
Ok(OidcAuthCallbackPayload { Ok(OidcAuthCallbackPayload {
@ -350,14 +350,14 @@ impl AuthServiceTrait for OidcAuthService {
if !claims.has_claim(key) { if !claims.has_claim(key) {
return Err(AuthError::OidcExtraClaimMissingError { claim: key.clone() }); return Err(AuthError::OidcExtraClaimMissingError { claim: key.clone() });
} }
if let Some(value) = config.extra_claim_value.as_ref() if let Some(value) = config.extra_claim_value.as_ref() {
&& claims.get_claim(key).is_none_or(|v| &v != value) if claims.get_claim(key).is_none_or(|v| &v != value) {
{ return Err(AuthError::OidcExtraClaimMatchError {
return Err(AuthError::OidcExtraClaimMatchError { expected: value.clone(),
expected: value.clone(), found: claims.get_claim(key).unwrap_or_default().to_string(),
found: claims.get_claim(key).unwrap_or_default().to_string(), key: key.clone(),
key: key.clone(), });
}); }
} }
} }
let subscriber_auth = match crate::models::auth::Model::find_by_pid(ctx, sub).await { let subscriber_auth = match crate::models::auth::Model::find_by_pid(ctx, sub).await {
@ -366,10 +366,7 @@ impl AuthServiceTrait for OidcAuthService {
}) => crate::models::auth::Model::create_from_oidc(ctx, sub.to_string()).await, }) => crate::models::auth::Model::create_from_oidc(ctx, sub.to_string()).await,
r => r, r => r,
} }
.map_err(|e| { .map_err(|_| AuthError::FindAuthRecordError)?;
tracing::error!("Error finding auth record: {:?}", e);
AuthError::FindAuthRecordError
})?;
Ok(AuthUserInfo { Ok(AuthUserInfo {
subscriber_auth, subscriber_auth,

View File

@ -1,4 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CryptoConfig {}

View File

@ -1,11 +0,0 @@
#[derive(Debug, snafu::Snafu)]
pub enum CryptoError {
#[snafu(transparent)]
Base64DecodeError { source: base64::DecodeError },
#[snafu(display("CocoonError: {source:?}"), context(false))]
CocoonError { source: cocoon::Error },
#[snafu(transparent)]
FromUtf8Error { source: std::string::FromUtf8Error },
#[snafu(transparent)]
SerdeJsonError { source: serde_json::Error },
}

View File

@ -1,9 +0,0 @@
pub mod config;
pub mod error;
pub mod service;
pub mod userpass;
pub use config::CryptoConfig;
pub use error::CryptoError;
pub use service::CryptoService;
pub use userpass::UserPassCredential;

View File

@ -1,65 +0,0 @@
use base64::prelude::{BASE64_URL_SAFE, *};
use cocoon::Cocoon;
use rand::Rng;
use serde::{Deserialize, Serialize};
use super::CryptoConfig;
use crate::crypto::error::CryptoError;
pub struct CryptoService {
#[allow(dead_code)]
config: CryptoConfig,
}
impl CryptoService {
pub async fn from_config(config: CryptoConfig) -> Result<Self, CryptoError> {
Ok(Self { config })
}
pub fn encrypt_data(&self, data: String) -> Result<String, CryptoError> {
let key = rand::rng().random::<[u8; 32]>();
let mut cocoon = Cocoon::new(&key);
let mut data = data.into_bytes();
let detached_prefix = cocoon.encrypt(&mut data)?;
let mut combined = Vec::with_capacity(key.len() + detached_prefix.len() + data.len());
combined.extend_from_slice(&key);
combined.extend_from_slice(&detached_prefix);
combined.extend_from_slice(&data);
Ok(BASE64_URL_SAFE.encode(combined))
}
pub fn decrypt_data(&self, data: &str) -> Result<String, CryptoError> {
let decoded = BASE64_URL_SAFE.decode(data)?;
let (key, remain) = decoded.split_at(32);
let (detached_prefix, data) = remain.split_at(60);
let mut data = data.to_vec();
let cocoon = Cocoon::new(key);
cocoon.decrypt(&mut data, detached_prefix)?;
String::from_utf8(data).map_err(CryptoError::from)
}
pub fn encrypt_credentials<T: Serialize>(
&self,
credentials: &T,
) -> Result<String, CryptoError> {
let json = serde_json::to_string(credentials)?;
self.encrypt_data(json)
}
pub fn decrypt_credentials<T: for<'de> Deserialize<'de>>(
&self,
encrypted: &str,
) -> Result<T, CryptoError> {
let data = self.decrypt_data(encrypted)?;
serde_json::from_str(&data).map_err(CryptoError::from)
}
}

View File

@ -1,19 +0,0 @@
use std::fmt::Debug;
pub struct UserPassCredential {
pub username: String,
pub password: String,
pub user_agent: Option<String>,
pub cookies: Option<String>,
}
impl Debug for UserPassCredential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("UserPassCredential")
.field("username", &"[Secret]")
.field("password", &"[Secret]")
.field("cookies", &"[Secret]")
.field("user_agent", &self.user_agent)
.finish()
}
}

View File

@ -1,23 +1,16 @@
use std::{ops::Deref, time::Duration}; use std::{ops::Deref, time::Duration};
use sea_orm::{ use sea_orm::{
ConnectOptions, ConnectionTrait, Database, DatabaseConnection, DbBackend, DbErr, ExecResult, ConnectOptions, ConnectionTrait, Database, DatabaseBackend, DatabaseConnection, DbBackend,
QueryResult, Statement, DbErr, ExecResult, QueryResult, Statement,
}; };
use sea_orm_migration::MigratorTrait; use sea_orm_migration::MigratorTrait;
use super::DatabaseConfig; use super::DatabaseConfig;
use crate::{errors::RecorderResult, migrations::Migrator}; use crate::{errors::RecorderResult, migrations::Migrator};
pub trait DatabaseServiceConnectionTrait {
fn get_database_connection(&self) -> &DatabaseConnection;
}
pub struct DatabaseService { pub struct DatabaseService {
connection: DatabaseConnection, connection: DatabaseConnection,
#[cfg(all(test, feature = "testcontainers"))]
pub container:
Option<testcontainers::ContainerAsync<testcontainers_modules::postgres::Postgres>>,
} }
impl DatabaseService { impl DatabaseService {
@ -35,31 +28,26 @@ impl DatabaseService {
let db = Database::connect(opt).await?; let db = Database::connect(opt).await?;
// only support postgres for now if db.get_database_backend() == DatabaseBackend::Sqlite {
// if db.get_database_backend() == DatabaseBackend::Sqlite { db.execute(Statement::from_string(
// db.execute(Statement::from_string( DatabaseBackend::Sqlite,
// DatabaseBackend::Sqlite, "
// " PRAGMA foreign_keys = ON;
// PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL;
// PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;
// PRAGMA synchronous = NORMAL; PRAGMA mmap_size = 134217728;
// PRAGMA mmap_size = 134217728; PRAGMA journal_size_limit = 67108864;
// PRAGMA journal_size_limit = 67108864; PRAGMA cache_size = 2000;
// PRAGMA cache_size = 2000; ",
// ", ))
// )) .await?;
// .await?; }
// }
if config.auto_migrate { if config.auto_migrate {
Migrator::up(&db, None).await?; Migrator::up(&db, None).await?;
} }
Ok(Self { Ok(Self { connection: db })
connection: db,
#[cfg(all(test, feature = "testcontainers"))]
container: None,
})
} }
} }

View File

@ -4,14 +4,13 @@ use axum::{
Json, Json,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use fetch::{FetchError, HttpClientError, reqwest, reqwest_middleware}; use fetch::{FetchError, HttpClientError};
use http::StatusCode; use http::StatusCode;
use serde::{Deserialize, Deserializer, Serialize}; use serde::{Deserialize, Deserializer, Serialize};
use snafu::Snafu; use snafu::Snafu;
use crate::{ use crate::{
auth::AuthError, auth::AuthError,
crypto::CryptoError,
downloader::DownloaderError, downloader::DownloaderError,
errors::{OptDynErr, response::StandardErrorResponse}, errors::{OptDynErr, response::StandardErrorResponse},
}; };
@ -103,14 +102,6 @@ pub enum RecorderError {
ModelEntityNotFound { entity: Cow<'static, str> }, ModelEntityNotFound { entity: Cow<'static, str> },
#[snafu(transparent)] #[snafu(transparent)]
FetchError { source: FetchError }, FetchError { source: FetchError },
#[snafu(display("Credential3rdError: {source}"))]
Credential3rdError {
message: String,
#[snafu(source(from(Box<dyn std::error::Error + Send + Sync>, OptDynErr::some)))]
source: OptDynErr,
},
#[snafu(transparent)]
CryptoError { source: CryptoError },
#[snafu(display("{message}"))] #[snafu(display("{message}"))]
Whatever { Whatever {
message: String, message: String,
@ -204,16 +195,4 @@ impl<'de> Deserialize<'de> for RecorderError {
} }
} }
impl From<reqwest::Error> for RecorderError {
fn from(error: reqwest::Error) -> Self {
FetchError::from(error).into()
}
}
impl From<reqwest_middleware::Error> for RecorderError {
fn from(error: reqwest_middleware::Error) -> Self {
FetchError::from(error).into()
}
}
pub type RecorderResult<T> = Result<T, RecorderError>; pub type RecorderResult<T> = Result<T, RecorderError>;

View File

@ -108,7 +108,7 @@ pub fn parse_episode_media_meta_from_torrent(
let media_name = torrent_path let media_name = torrent_path
.file_name() .file_name()
.with_whatever_context::<_, _, RecorderError>(|| { .with_whatever_context::<_, _, RecorderError>(|| {
format!("failed to get file name of {torrent_path}") format!("failed to get file name of {}", torrent_path)
})?; })?;
let mut match_obj = None; let mut match_obj = None;
for rule in TORRENT_EP_PARSE_RULES.iter() { for rule in TORRENT_EP_PARSE_RULES.iter() {
@ -141,7 +141,7 @@ pub fn parse_episode_media_meta_from_torrent(
.unwrap_or(1); .unwrap_or(1);
let extname = torrent_path let extname = torrent_path
.extension() .extension()
.map(|e| format!(".{e}")) .map(|e| format!(".{}", e))
.unwrap_or_default(); .unwrap_or_default();
Ok(TorrentEpisodeMediaMeta { Ok(TorrentEpisodeMediaMeta {
fansub: fansub.map(|s| s.to_string()), fansub: fansub.map(|s| s.to_string()),
@ -168,7 +168,7 @@ pub fn parse_episode_subtitle_meta_from_torrent(
let media_name = torrent_path let media_name = torrent_path
.file_name() .file_name()
.with_whatever_context::<_, _, RecorderError>(|| { .with_whatever_context::<_, _, RecorderError>(|| {
format!("failed to get file name of {torrent_path}") format!("failed to get file name of {}", torrent_path)
})?; })?;
let lang = get_subtitle_lang(media_name); let lang = get_subtitle_lang(media_name);
@ -271,7 +271,7 @@ mod tests {
pub fn test_torrent_ep_parser(raw_name: &str, expected: &str) { pub fn test_torrent_ep_parser(raw_name: &str, expected: &str) {
let extname = Path::new(raw_name) let extname = Path::new(raw_name)
.extension() .extension()
.map(|e| format!(".{e}")) .map(|e| format!(".{}", e))
.unwrap_or_default() .unwrap_or_default()
.to_lowercase(); .to_lowercase();

View File

@ -19,19 +19,21 @@ pub fn extract_background_image_src_from_style_attr(
match prop { match prop {
Property::BackgroundImage(images) => { Property::BackgroundImage(images) => {
for img in images { for img in images {
if let CSSImage::Url(path) = img if let CSSImage::Url(path) = img {
&& let Some(url) = extract_image_src_from_str(path.url.trim(), base_url) if let Some(url) = extract_image_src_from_str(path.url.trim(), base_url)
{ {
return Some(url); return Some(url);
}
} }
} }
} }
Property::Background(backgrounds) => { Property::Background(backgrounds) => {
for bg in backgrounds { for bg in backgrounds {
if let CSSImage::Url(path) = &bg.image if let CSSImage::Url(path) = &bg.image {
&& let Some(url) = extract_image_src_from_str(path.url.trim(), base_url) if let Some(url) = extract_image_src_from_str(path.url.trim(), base_url)
{ {
return Some(url); return Some(url);
}
} }
} }
} }

View File

@ -1,4 +1,4 @@
use axum::http::{HeaderName, HeaderValue, Uri, header, request::Parts}; use axum::http::{header, request::Parts, HeaderName, HeaderValue, Uri};
use itertools::Itertools; use itertools::Itertools;
use url::Url; use url::Url;
@ -121,7 +121,11 @@ impl ForwardedRelatedInfo {
.and_then(|s| s.to_str().ok()) .and_then(|s| s.to_str().ok())
.and_then(|s| { .and_then(|s| {
let l = s.split(",").map(|s| s.trim().to_string()).collect_vec(); let l = s.split(",").map(|s| s.trim().to_string()).collect_vec();
if l.is_empty() { None } else { Some(l) } if l.is_empty() {
None
} else {
Some(l)
}
}); });
let host = headers let host = headers
@ -161,7 +165,7 @@ impl ForwardedRelatedInfo {
pub fn resolved_origin(&self) -> Option<Url> { pub fn resolved_origin(&self) -> Option<Url> {
if let (Some(protocol), Some(host)) = (self.resolved_protocol(), self.resolved_host()) { if let (Some(protocol), Some(host)) = (self.resolved_protocol(), self.resolved_host()) {
let origin = format!("{protocol}://{host}"); let origin = format!("{}://{}", protocol, host);
Url::parse(&origin).ok() Url::parse(&origin).ok()
} else { } else {
None None

View File

@ -2,10 +2,7 @@ use url::Url;
pub fn extract_image_src_from_str(image_src: &str, base_url: &Url) -> Option<Url> { pub fn extract_image_src_from_str(image_src: &str, base_url: &Url) -> Option<Url> {
let mut image_url = base_url.join(image_src).ok()?; let mut image_url = base_url.join(image_src).ok()?;
if let Some((_, value)) = image_url.query_pairs().find(|(key, _)| key == "webp") { image_url.set_query(None);
image_url.set_query(Some(&format!("webp={value}"))); image_url.set_fragment(None);
} else {
image_url.set_query(None);
}
Some(image_url) Some(image_url)
} }

View File

@ -1,227 +1,61 @@
use std::{fmt::Debug, ops::Deref, sync::Arc}; use std::{fmt::Debug, ops::Deref};
use fetch::{HttpClient, HttpClientTrait}; use fetch::{HttpClient, HttpClientTrait, client::HttpClientCookiesAuth};
use maplit::hashmap; use serde::{Deserialize, Serialize};
use sea_orm::{
ActiveModelTrait, ActiveValue::Set, ColumnTrait, DbErr, EntityTrait, QueryFilter, TryIntoModel,
};
use url::Url; use url::Url;
use util::OptDynErr;
use super::{MikanConfig, MikanCredentialForm, constants::MIKAN_ACCOUNT_MANAGE_PAGE_PATH}; use super::MikanConfig;
use crate::{ use crate::errors::RecorderError;
app::AppContextTrait, #[derive(Default, Clone, Deserialize, Serialize)]
crypto::UserPassCredential, pub struct MikanAuthSecrecy {
errors::{RecorderError, RecorderResult}, pub cookie: String,
extract::mikan::constants::{MIKAN_LOGIN_PAGE_PATH, MIKAN_LOGIN_PAGE_SEARCH}, pub user_agent: Option<String>,
models::credential_3rd::{self, Credential3rdType}, }
};
impl Debug for MikanAuthSecrecy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MikanAuthSecrecy")
.field("cookie", &String::from("[secrecy]"))
.field("user_agent", &String::from("[secrecy]"))
.finish()
}
}
impl MikanAuthSecrecy {
pub fn into_cookie_auth(self, url: &Url) -> Result<HttpClientCookiesAuth, RecorderError> {
HttpClientCookiesAuth::from_cookies(&self.cookie, url, self.user_agent)
.map_err(RecorderError::from)
}
}
#[derive(Debug)] #[derive(Debug)]
pub struct MikanClient { pub struct MikanClient {
http_client: HttpClient, http_client: HttpClient,
base_url: Url, base_url: Url,
origin_url: Url,
userpass_credential: Option<UserPassCredential>,
} }
impl MikanClient { impl MikanClient {
pub async fn from_config(config: MikanConfig) -> Result<Self, RecorderError> { pub async fn from_config(config: MikanConfig) -> Result<Self, RecorderError> {
let http_client = HttpClient::from_config(config.http_client)?; let http_client = HttpClient::from_config(config.http_client)?;
let base_url = config.base_url; let base_url = config.base_url;
let origin_url = Url::parse(&base_url.origin().unicode_serialization())?;
Ok(Self { Ok(Self {
http_client, http_client,
base_url, base_url,
origin_url,
userpass_credential: None,
}) })
} }
pub async fn has_login(&self) -> RecorderResult<bool> { pub fn fork_with_auth(&self, secrecy: Option<MikanAuthSecrecy>) -> Result<Self, RecorderError> {
let account_manage_page_url = self.base_url.join(MIKAN_ACCOUNT_MANAGE_PAGE_PATH)?;
let res = self.http_client.get(account_manage_page_url).send().await?;
let status = res.status();
if status.is_success() {
Ok(true)
} else if status.is_redirection()
&& res.headers().get("location").is_some_and(|location| {
location
.to_str()
.is_ok_and(|location_str| location_str.contains(MIKAN_LOGIN_PAGE_PATH))
})
{
Ok(false)
} else {
Err(RecorderError::Credential3rdError {
message: format!("mikan account check has login failed, status = {status}"),
source: None.into(),
})
}
}
pub async fn login(&self) -> RecorderResult<()> {
let userpass_credential =
self.userpass_credential
.as_ref()
.ok_or_else(|| RecorderError::Credential3rdError {
message: "mikan login failed, credential required".to_string(),
source: None.into(),
})?;
let login_page_url = {
let mut u = self.base_url.join(MIKAN_LOGIN_PAGE_PATH)?;
u.set_query(Some(MIKAN_LOGIN_PAGE_SEARCH));
u
};
// access login page to get antiforgery cookie
self.http_client
.get(login_page_url.clone())
.send()
.await
.map_err(|error| RecorderError::Credential3rdError {
message: "failed to get mikan login page".to_string(),
source: OptDynErr::some_boxed(error),
})?;
let antiforgery_cookie = {
let cookie_store_lock = self.http_client.cookie_store.clone().ok_or_else(|| {
RecorderError::Credential3rdError {
message: "failed to get cookie store".to_string(),
source: None.into(),
}
})?;
let cookie_store =
cookie_store_lock
.read()
.map_err(|_| RecorderError::Credential3rdError {
message: "failed to read cookie store".to_string(),
source: None.into(),
})?;
cookie_store
.matches(&login_page_url)
.iter()
.find(|cookie| cookie.name().starts_with(".AspNetCore.Antiforgery."))
.map(|cookie| cookie.value().to_string())
}
.ok_or_else(|| RecorderError::Credential3rdError {
message: "mikan login failed, failed to get antiforgery cookie".to_string(),
source: None.into(),
})?;
let login_post_form = hashmap! {
"__RequestVerificationToken".to_string() => antiforgery_cookie,
"UserName".to_string() => userpass_credential.username.clone(),
"Password".to_string() => userpass_credential.password.clone(),
"RememberMe".to_string() => "true".to_string(),
};
let login_post_res = self
.http_client
.post(login_page_url.clone())
.form(&login_post_form)
.send()
.await
.map_err(|err| RecorderError::Credential3rdError {
message: "mikan login failed".to_string(),
source: OptDynErr::some_boxed(err),
})?;
if login_post_res.status().is_redirection()
&& login_post_res.headers().contains_key("location")
{
Ok(())
} else {
Err(RecorderError::Credential3rdError {
message: "mikan login failed, no redirecting".to_string(),
source: None.into(),
})
}
}
pub async fn submit_credential_form(
&self,
ctx: Arc<dyn AppContextTrait>,
subscriber_id: i32,
credential_form: MikanCredentialForm,
) -> RecorderResult<credential_3rd::Model> {
let db = ctx.db();
let am = credential_3rd::ActiveModel {
username: Set(Some(credential_form.username)),
password: Set(Some(credential_form.password)),
user_agent: Set(Some(credential_form.user_agent)),
credential_type: Set(Credential3rdType::Mikan),
subscriber_id: Set(subscriber_id),
..Default::default()
}
.try_encrypt(ctx.clone())
.await?;
let credential: credential_3rd::Model = am.save(db).await?.try_into_model()?;
Ok(credential)
}
pub async fn sync_credential_cookies(
&self,
ctx: Arc<dyn AppContextTrait>,
credential_id: i32,
) -> RecorderResult<()> {
let cookies = self.http_client.save_cookie_store_to_json()?;
if let Some(cookies) = cookies {
let am = credential_3rd::ActiveModel {
cookies: Set(Some(cookies)),
..Default::default()
}
.try_encrypt(ctx.clone())
.await?;
credential_3rd::Entity::update_many()
.set(am)
.filter(credential_3rd::Column::Id.eq(credential_id))
.exec(ctx.db())
.await?;
}
Ok(())
}
pub async fn fork_with_credential(
&self,
ctx: Arc<dyn AppContextTrait>,
credential_id: i32,
) -> RecorderResult<Self> {
let mut fork = self.http_client.fork(); let mut fork = self.http_client.fork();
let credential = credential_3rd::Model::find_by_id(ctx.clone(), credential_id).await?; if let Some(secrecy) = secrecy {
if let Some(credential) = credential { let cookie_auth = secrecy.into_cookie_auth(&self.base_url)?;
if credential.credential_type != Credential3rdType::Mikan { fork = fork.attach_secrecy(cookie_auth);
return Err(RecorderError::Credential3rdError {
message: "credential is not a mikan credential".to_string(),
source: None.into(),
});
}
let userpass_credential: UserPassCredential =
credential.try_into_userpass_credential(ctx)?;
fork = fork.attach_cookies(userpass_credential.cookies.as_deref())?;
if let Some(user_agent) = userpass_credential.user_agent.as_ref() {
fork = fork.attach_user_agent(user_agent);
}
let userpass_credential_opt = Some(userpass_credential);
Ok(Self {
http_client: HttpClient::from_fork(fork)?,
base_url: self.base_url.clone(),
origin_url: self.origin_url.clone(),
userpass_credential: userpass_credential_opt,
})
} else {
Err(RecorderError::from_db_record_not_found(
DbErr::RecordNotFound(format!("credential={credential_id} not found")),
))
} }
Ok(Self {
http_client: HttpClient::from_fork(fork)?,
base_url: self.base_url.clone(),
})
} }
pub fn base_url(&self) -> &Url { pub fn base_url(&self) -> &Url {

View File

@ -1,8 +1,3 @@
pub const MIKAN_POSTER_BUCKET_KEY: &str = "mikan_poster"; pub const MIKAN_BUCKET_KEY: &str = "mikan";
pub const MIKAN_UNKNOWN_FANSUB_NAME: &str = "生肉/不明字幕"; pub const MIKAN_UNKNOWN_FANSUB_NAME: &str = "生肉/不明字幕";
pub const MIKAN_UNKNOWN_FANSUB_ID: &str = "202"; pub const MIKAN_UNKNOWN_FANSUB_ID: &str = "202";
pub const MIKAN_LOGIN_PAGE_PATH: &str = "/Account/Login";
pub const MIKAN_LOGIN_PAGE_SEARCH: &str = "ReturnUrl=%2F";
pub const MIKAN_ACCOUNT_MANAGE_PAGE_PATH: &str = "/Account/Manage";
pub const MIKAN_SEASON_FLOW_PAGE_PATH: &str = "/Home/BangumiCoverFlow";
pub const MIKAN_BANGUMI_EXPAND_SUBSCRIBED_PAGE_PATH: &str = "/Home/ExpandBangumi";

View File

@ -1,20 +0,0 @@
use std::fmt::Debug;
use serde::{Deserialize, Serialize};
#[derive(Default, Clone, Deserialize, Serialize)]
pub struct MikanCredentialForm {
pub password: String,
pub username: String,
pub user_agent: String,
}
impl Debug for MikanCredentialForm {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MikanCredentialForm")
.field("username", &String::from("[secrecy]"))
.field("password", &String::from("[secrecy]"))
.field("user_agent", &String::from("[secrecy]"))
.finish()
}
}

View File

@ -1,34 +1,21 @@
mod client; pub mod client;
mod config; pub mod config;
mod constants; pub mod constants;
mod credential; pub mod rss_extract;
mod rss; pub mod web_extract;
mod web;
pub use client::MikanClient; pub use client::{MikanAuthSecrecy, MikanClient};
pub use config::MikanConfig; pub use config::MikanConfig;
pub use constants::{ pub use constants::MIKAN_BUCKET_KEY;
MIKAN_ACCOUNT_MANAGE_PAGE_PATH, MIKAN_BANGUMI_EXPAND_SUBSCRIBED_PAGE_PATH, pub use rss_extract::{
MIKAN_LOGIN_PAGE_PATH, MIKAN_LOGIN_PAGE_SEARCH, MIKAN_POSTER_BUCKET_KEY, MikanBangumiAggregationRssChannel, MikanBangumiRssChannel, MikanBangumiRssLink,
MIKAN_SEASON_FLOW_PAGE_PATH, MIKAN_UNKNOWN_FANSUB_ID, MIKAN_UNKNOWN_FANSUB_NAME, MikanRssChannel, MikanRssItem, MikanSubscriberAggregationRssChannel,
MikanSubscriberAggregationRssLink, build_mikan_bangumi_rss_link,
build_mikan_subscriber_aggregation_rss_link, extract_mikan_bangumi_id_from_rss_link,
extract_mikan_rss_channel_from_rss_link, extract_mikan_subscriber_aggregation_id_from_rss_link,
}; };
pub use credential::MikanCredentialForm; pub use web_extract::{
pub use rss::{ MikanBangumiMeta, MikanEpisodeMeta, build_mikan_bangumi_homepage, build_mikan_episode_homepage,
MikanBangumiIndexRssChannel, MikanBangumiRssChannel, MikanBangumiRssUrlMeta, MikanRssChannel, extract_mikan_bangumi_meta_from_bangumi_homepage,
MikanRssItem, MikanSubscriberAggregationRssUrlMeta, MikanSubscriberStreamRssChannel, extract_mikan_episode_meta_from_episode_homepage,
build_mikan_bangumi_rss_url, build_mikan_subscriber_aggregation_rss_url,
extract_mikan_bangumi_id_from_rss_url, extract_mikan_rss_channel_from_rss_link,
extract_mikan_subscriber_aggregation_id_from_rss_link,
};
pub use web::{
MikanBangumiHomepageUrlMeta, MikanBangumiIndexHomepageUrlMeta, MikanBangumiIndexMeta,
MikanBangumiMeta, MikanBangumiPosterMeta, MikanEpisodeHomepageUrlMeta, MikanEpisodeMeta,
MikanSeasonFlowUrlMeta, MikanSeasonStr, build_mikan_bangumi_expand_subscribed_url,
build_mikan_bangumi_homepage_url, build_mikan_episode_homepage_url,
build_mikan_season_flow_url, extract_mikan_bangumi_index_meta_list_from_season_flow_fragment,
extract_mikan_episode_meta_from_episode_homepage_html,
scrape_mikan_bangumi_meta_from_bangumi_homepage_url,
scrape_mikan_bangumi_meta_list_from_season_flow_url,
scrape_mikan_episode_meta_from_episode_homepage_url, scrape_mikan_poster_data_from_image_url,
scrape_mikan_poster_meta_from_image_url,
}; };

View File

@ -10,7 +10,10 @@ use url::Url;
use crate::{ use crate::{
errors::app_error::{RecorderError, RecorderResult}, errors::app_error::{RecorderError, RecorderResult},
extract::mikan::{MikanClient, MikanEpisodeHomepageUrlMeta}, extract::mikan::{
MikanClient,
web_extract::{MikanEpisodeHomepage, extract_mikan_episode_id_from_homepage},
},
}; };
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@ -34,7 +37,7 @@ pub struct MikanBangumiRssChannel {
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MikanBangumiIndexRssChannel { pub struct MikanBangumiAggregationRssChannel {
pub name: String, pub name: String,
pub url: Url, pub url: Url,
pub mikan_bangumi_id: String, pub mikan_bangumi_id: String,
@ -42,7 +45,7 @@ pub struct MikanBangumiIndexRssChannel {
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MikanSubscriberStreamRssChannel { pub struct MikanSubscriberAggregationRssChannel {
pub mikan_aggregation_id: String, pub mikan_aggregation_id: String,
pub url: Url, pub url: Url,
pub items: Vec<MikanRssItem>, pub items: Vec<MikanRssItem>,
@ -51,40 +54,46 @@ pub struct MikanSubscriberStreamRssChannel {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum MikanRssChannel { pub enum MikanRssChannel {
Bangumi(MikanBangumiRssChannel), Bangumi(MikanBangumiRssChannel),
BangumiIndex(MikanBangumiIndexRssChannel), BangumiAggregation(MikanBangumiAggregationRssChannel),
SubscriberStream(MikanSubscriberStreamRssChannel), SubscriberAggregation(MikanSubscriberAggregationRssChannel),
} }
impl MikanRssChannel { impl MikanRssChannel {
pub fn items(&self) -> &[MikanRssItem] { pub fn items(&self) -> &[MikanRssItem] {
match &self { match &self {
Self::Bangumi(MikanBangumiRssChannel { items, .. }) Self::Bangumi(MikanBangumiRssChannel { items, .. })
| Self::BangumiIndex(MikanBangumiIndexRssChannel { items, .. }) | Self::BangumiAggregation(MikanBangumiAggregationRssChannel { items, .. })
| Self::SubscriberStream(MikanSubscriberStreamRssChannel { items, .. }) => items, | Self::SubscriberAggregation(MikanSubscriberAggregationRssChannel { items, .. }) => {
items
}
} }
} }
pub fn into_items(self) -> Vec<MikanRssItem> { pub fn into_items(self) -> Vec<MikanRssItem> {
match self { match self {
Self::Bangumi(MikanBangumiRssChannel { items, .. }) Self::Bangumi(MikanBangumiRssChannel { items, .. })
| Self::BangumiIndex(MikanBangumiIndexRssChannel { items, .. }) | Self::BangumiAggregation(MikanBangumiAggregationRssChannel { items, .. })
| Self::SubscriberStream(MikanSubscriberStreamRssChannel { items, .. }) => items, | Self::SubscriberAggregation(MikanSubscriberAggregationRssChannel { items, .. }) => {
items
}
} }
} }
pub fn name(&self) -> Option<&str> { pub fn name(&self) -> Option<&str> {
match &self { match &self {
Self::Bangumi(MikanBangumiRssChannel { name, .. }) Self::Bangumi(MikanBangumiRssChannel { name, .. })
| Self::BangumiIndex(MikanBangumiIndexRssChannel { name, .. }) => Some(name.as_str()), | Self::BangumiAggregation(MikanBangumiAggregationRssChannel { name, .. }) => {
Self::SubscriberStream(MikanSubscriberStreamRssChannel { .. }) => None, Some(name.as_str())
}
Self::SubscriberAggregation(MikanSubscriberAggregationRssChannel { .. }) => None,
} }
} }
pub fn url(&self) -> &Url { pub fn url(&self) -> &Url {
match &self { match &self {
Self::Bangumi(MikanBangumiRssChannel { url, .. }) Self::Bangumi(MikanBangumiRssChannel { url, .. })
| Self::BangumiIndex(MikanBangumiIndexRssChannel { url, .. }) | Self::BangumiAggregation(MikanBangumiAggregationRssChannel { url, .. })
| Self::SubscriberStream(MikanSubscriberStreamRssChannel { url, .. }) => url, | Self::SubscriberAggregation(MikanSubscriberAggregationRssChannel { url, .. }) => url,
} }
} }
} }
@ -124,9 +133,9 @@ impl TryFrom<rss::Item> for MikanRssItem {
RecorderError::from_mikan_rss_invalid_field(Cow::Borrowed("homepage:link")) RecorderError::from_mikan_rss_invalid_field(Cow::Borrowed("homepage:link"))
})?; })?;
let MikanEpisodeHomepageUrlMeta { let MikanEpisodeHomepage {
mikan_episode_id, .. mikan_episode_id, ..
} = MikanEpisodeHomepageUrlMeta::parse_url(&homepage).ok_or_else(|| { } = extract_mikan_episode_id_from_homepage(&homepage).ok_or_else(|| {
RecorderError::from_mikan_rss_invalid_field(Cow::Borrowed("mikan_episode_id")) RecorderError::from_mikan_rss_invalid_field(Cow::Borrowed("mikan_episode_id"))
})?; })?;
@ -146,17 +155,17 @@ impl TryFrom<rss::Item> for MikanRssItem {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct MikanBangumiRssUrlMeta { pub struct MikanBangumiRssLink {
pub mikan_bangumi_id: String, pub mikan_bangumi_id: String,
pub mikan_fansub_id: Option<String>, pub mikan_fansub_id: Option<String>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct MikanSubscriberAggregationRssUrlMeta { pub struct MikanSubscriberAggregationRssLink {
pub mikan_aggregation_id: String, pub mikan_aggregation_id: String,
} }
pub fn build_mikan_bangumi_rss_url( pub fn build_mikan_bangumi_rss_link(
mikan_base_url: impl IntoUrl, mikan_base_url: impl IntoUrl,
mikan_bangumi_id: &str, mikan_bangumi_id: &str,
mikan_fansub_id: Option<&str>, mikan_fansub_id: Option<&str>,
@ -172,7 +181,7 @@ pub fn build_mikan_bangumi_rss_url(
Ok(url) Ok(url)
} }
pub fn build_mikan_subscriber_aggregation_rss_url( pub fn build_mikan_subscriber_aggregation_rss_link(
mikan_base_url: &str, mikan_base_url: &str,
mikan_aggregation_id: &str, mikan_aggregation_id: &str,
) -> RecorderResult<Url> { ) -> RecorderResult<Url> {
@ -183,11 +192,11 @@ pub fn build_mikan_subscriber_aggregation_rss_url(
Ok(url) Ok(url)
} }
pub fn extract_mikan_bangumi_id_from_rss_url(url: &Url) -> Option<MikanBangumiRssUrlMeta> { pub fn extract_mikan_bangumi_id_from_rss_link(url: &Url) -> Option<MikanBangumiRssLink> {
if url.path() == "/RSS/Bangumi" { if url.path() == "/RSS/Bangumi" {
url.query_pairs() url.query_pairs()
.find(|(k, _)| k == "bangumiId") .find(|(k, _)| k == "bangumiId")
.map(|(_, v)| MikanBangumiRssUrlMeta { .map(|(_, v)| MikanBangumiRssLink {
mikan_bangumi_id: v.to_string(), mikan_bangumi_id: v.to_string(),
mikan_fansub_id: url mikan_fansub_id: url
.query_pairs() .query_pairs()
@ -201,10 +210,10 @@ pub fn extract_mikan_bangumi_id_from_rss_url(url: &Url) -> Option<MikanBangumiRs
pub fn extract_mikan_subscriber_aggregation_id_from_rss_link( pub fn extract_mikan_subscriber_aggregation_id_from_rss_link(
url: &Url, url: &Url,
) -> Option<MikanSubscriberAggregationRssUrlMeta> { ) -> Option<MikanSubscriberAggregationRssLink> {
if url.path() == "/RSS/MyBangumi" { if url.path() == "/RSS/MyBangumi" {
url.query_pairs().find(|(k, _)| k == "token").map(|(_, v)| { url.query_pairs().find(|(k, _)| k == "token").map(|(_, v)| {
MikanSubscriberAggregationRssUrlMeta { MikanSubscriberAggregationRssLink {
mikan_aggregation_id: v.to_string(), mikan_aggregation_id: v.to_string(),
} }
}) })
@ -224,10 +233,10 @@ pub async fn extract_mikan_rss_channel_from_rss_link(
let channel_link = Url::parse(channel.link())?; let channel_link = Url::parse(channel.link())?;
if let Some(MikanBangumiRssUrlMeta { if let Some(MikanBangumiRssLink {
mikan_bangumi_id, mikan_bangumi_id,
mikan_fansub_id, mikan_fansub_id,
}) = extract_mikan_bangumi_id_from_rss_url(&channel_link) }) = extract_mikan_bangumi_id_from_rss_link(&channel_link)
{ {
tracing::trace!( tracing::trace!(
mikan_bangumi_id, mikan_bangumi_id,
@ -269,17 +278,19 @@ pub async fn extract_mikan_rss_channel_from_rss_link(
channel_name, channel_name,
channel_link = channel_link.as_str(), channel_link = channel_link.as_str(),
mikan_bangumi_id, mikan_bangumi_id,
"MikanBangumiIndexRssChannel extracted" "MikanBangumiAggregationRssChannel extracted"
); );
Ok(MikanRssChannel::BangumiIndex(MikanBangumiIndexRssChannel { Ok(MikanRssChannel::BangumiAggregation(
name: channel_name, MikanBangumiAggregationRssChannel {
mikan_bangumi_id, name: channel_name,
url: channel_link, mikan_bangumi_id,
items, url: channel_link,
})) items,
},
))
} }
} else if let Some(MikanSubscriberAggregationRssUrlMeta { } else if let Some(MikanSubscriberAggregationRssLink {
mikan_aggregation_id, mikan_aggregation_id,
.. ..
}) = extract_mikan_subscriber_aggregation_id_from_rss_link(&channel_link) }) = extract_mikan_subscriber_aggregation_id_from_rss_link(&channel_link)
@ -306,8 +317,8 @@ pub async fn extract_mikan_rss_channel_from_rss_link(
"MikanSubscriberAggregationRssChannel extracted" "MikanSubscriberAggregationRssChannel extracted"
); );
Ok(MikanRssChannel::SubscriberStream( Ok(MikanRssChannel::SubscriberAggregation(
MikanSubscriberStreamRssChannel { MikanSubscriberAggregationRssChannel {
mikan_aggregation_id, mikan_aggregation_id,
items, items,
url: channel_link, url: channel_link,
@ -331,7 +342,7 @@ mod tests {
use crate::{ use crate::{
errors::RecorderResult, errors::RecorderResult,
extract::mikan::{ extract::mikan::{
MikanBangumiIndexRssChannel, MikanBangumiRssChannel, MikanRssChannel, MikanBangumiAggregationRssChannel, MikanBangumiRssChannel, MikanRssChannel,
extract_mikan_rss_channel_from_rss_link, extract_mikan_rss_channel_from_rss_link,
}, },
test_utils::mikan::build_testing_mikan_client, test_utils::mikan::build_testing_mikan_client,
@ -402,7 +413,7 @@ mod tests {
assert_matches!( assert_matches!(
&channel, &channel,
MikanRssChannel::BangumiIndex(MikanBangumiIndexRssChannel { .. }) MikanRssChannel::BangumiAggregation(MikanBangumiAggregationRssChannel { .. })
); );
assert_matches!(&channel.name(), Some("叹气的亡灵想隐退")); assert_matches!(&channel.name(), Some("叹气的亡灵想隐退"));

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,720 @@
use std::{borrow::Cow, sync::Arc};
use async_stream::try_stream;
use bytes::Bytes;
use fetch::{html::fetch_html, image::fetch_image};
use futures::Stream;
use itertools::Itertools;
use scraper::{Html, Selector};
use serde::{Deserialize, Serialize};
use tracing::instrument;
use url::Url;
use super::{
MIKAN_BUCKET_KEY, MikanAuthSecrecy, MikanBangumiRssLink, MikanClient,
extract_mikan_bangumi_id_from_rss_link,
};
use crate::{
app::AppContextTrait,
errors::app_error::{RecorderResult, RecorderError},
extract::{
html::{extract_background_image_src_from_style_attr, extract_inner_text_from_element_ref},
media::extract_image_src_from_str,
},
storage::StorageContentCategory,
};
#[derive(Clone, Debug, PartialEq)]
pub struct MikanEpisodeMeta {
pub homepage: Url,
pub origin_poster_src: Option<Url>,
pub bangumi_title: String,
pub episode_title: String,
pub fansub: String,
pub mikan_bangumi_id: String,
pub mikan_fansub_id: String,
pub mikan_episode_id: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MikanBangumiMeta {
pub homepage: Url,
pub origin_poster_src: Option<Url>,
pub bangumi_title: String,
pub mikan_bangumi_id: String,
pub mikan_fansub_id: Option<String>,
pub fansub: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct MikanBangumiPosterMeta {
pub origin_poster_src: Url,
pub poster_data: Option<Bytes>,
pub poster_src: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct MikanEpisodeHomepage {
pub mikan_episode_id: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct MikanBangumiHomepage {
pub mikan_bangumi_id: String,
pub mikan_fansub_id: Option<String>,
}
pub fn build_mikan_bangumi_homepage(
mikan_base_url: Url,
mikan_bangumi_id: &str,
mikan_fansub_id: Option<&str>,
) -> Url {
let mut url = mikan_base_url;
url.set_path(&format!("/Home/Bangumi/{mikan_bangumi_id}"));
url.set_fragment(mikan_fansub_id);
url
}
pub fn build_mikan_episode_homepage(mikan_base_url: Url, mikan_episode_id: &str) -> Url {
let mut url = mikan_base_url;
url.set_path(&format!("/Home/Episode/{mikan_episode_id}"));
url
}
pub fn build_mikan_bangumi_expand_info_url(mikan_base_url: Url, mikan_bangumi_id: &str) -> Url {
let mut url = mikan_base_url;
url.set_path("/ExpandBangumi");
url.query_pairs_mut()
.append_pair("bangumiId", mikan_bangumi_id)
.append_pair("showSubscribed", "true");
url
}
pub fn extract_mikan_bangumi_id_from_homepage(url: &Url) -> Option<MikanBangumiHomepage> {
if url.path().starts_with("/Home/Bangumi/") {
let mikan_bangumi_id = url.path().replace("/Home/Bangumi/", "");
Some(MikanBangumiHomepage {
mikan_bangumi_id,
mikan_fansub_id: url.fragment().map(String::from),
})
} else {
None
}
}
pub fn extract_mikan_episode_id_from_homepage(url: &Url) -> Option<MikanEpisodeHomepage> {
if url.path().starts_with("/Home/Episode/") {
let mikan_episode_id = url.path().replace("/Home/Episode/", "");
Some(MikanEpisodeHomepage { mikan_episode_id })
} else {
None
}
}
pub async fn extract_mikan_poster_meta_from_src(
http_client: &MikanClient,
origin_poster_src_url: Url,
) -> Result<MikanBangumiPosterMeta, RecorderError> {
let poster_data = fetch_image(http_client, origin_poster_src_url.clone()).await?;
Ok(MikanBangumiPosterMeta {
origin_poster_src: origin_poster_src_url,
poster_data: Some(poster_data),
poster_src: None,
})
}
pub async fn extract_mikan_bangumi_poster_meta_from_src_with_cache(
ctx: &dyn AppContextTrait,
origin_poster_src_url: Url,
subscriber_id: i32,
) -> RecorderResult<MikanBangumiPosterMeta> {
let dal_client = ctx.storage();
let mikan_client = ctx.mikan();
if let Some(poster_src) = dal_client
.exists_object(
StorageContentCategory::Image,
subscriber_id,
Some(MIKAN_BUCKET_KEY),
&origin_poster_src_url.path().replace("/images/Bangumi/", ""),
)
.await?
{
return Ok(MikanBangumiPosterMeta {
origin_poster_src: origin_poster_src_url,
poster_data: None,
poster_src: Some(poster_src.to_string()),
});
}
let poster_data = fetch_image(mikan_client, origin_poster_src_url.clone()).await?;
let poster_str = dal_client
.store_object(
StorageContentCategory::Image,
subscriber_id,
Some(MIKAN_BUCKET_KEY),
&origin_poster_src_url.path().replace("/images/Bangumi/", ""),
poster_data.clone(),
)
.await?;
Ok(MikanBangumiPosterMeta {
origin_poster_src: origin_poster_src_url,
poster_data: Some(poster_data),
poster_src: Some(poster_str.to_string()),
})
}
#[instrument(skip_all, fields(mikan_episode_homepage_url = mikan_episode_homepage_url.as_str()))]
pub async fn extract_mikan_episode_meta_from_episode_homepage(
http_client: &MikanClient,
mikan_episode_homepage_url: Url,
) -> Result<MikanEpisodeMeta, RecorderError> {
let mikan_base_url = Url::parse(&mikan_episode_homepage_url.origin().unicode_serialization())?;
let content = fetch_html(http_client, mikan_episode_homepage_url.as_str()).await?;
let html = Html::parse_document(&content);
let bangumi_title_selector =
&Selector::parse(".bangumi-title > a[href^='/Home/Bangumi/']").unwrap();
let mikan_bangumi_id_selector =
&Selector::parse(".bangumi-title > a.mikan-rss[data-original-title='RSS']").unwrap();
let bangumi_poster_selector = &Selector::parse(".bangumi-poster").unwrap();
let bangumi_title = html
.select(bangumi_title_selector)
.next()
.map(extract_inner_text_from_element_ref)
.ok_or_else(|| RecorderError::from_mikan_meta_missing_field(Cow::Borrowed("bangumi_title")))
.inspect_err(|error| {
tracing::warn!(error = %error);
})?;
let MikanBangumiRssLink {
mikan_bangumi_id,
mikan_fansub_id,
..
} = html
.select(mikan_bangumi_id_selector)
.next()
.and_then(|el| el.value().attr("href"))
.and_then(|s| mikan_episode_homepage_url.join(s).ok())
.and_then(|rss_link_url| extract_mikan_bangumi_id_from_rss_link(&rss_link_url))
.ok_or_else(|| {
RecorderError::from_mikan_meta_missing_field(Cow::Borrowed("mikan_bangumi_id"))
})
.inspect_err(|error| tracing::error!(error = %error))?;
let mikan_fansub_id = mikan_fansub_id
.ok_or_else(|| {
RecorderError::from_mikan_meta_missing_field(Cow::Borrowed("mikan_fansub_id"))
})
.inspect_err(|error| tracing::error!(error = %error))?;
let episode_title = html
.select(&Selector::parse("title").unwrap())
.next()
.map(extract_inner_text_from_element_ref)
.ok_or_else(|| RecorderError::from_mikan_meta_missing_field(Cow::Borrowed("episode_title")))
.inspect_err(|error| {
tracing::warn!(error = %error);
})?;
let MikanEpisodeHomepage {
mikan_episode_id, ..
} = extract_mikan_episode_id_from_homepage(&mikan_episode_homepage_url)
.ok_or_else(|| {
RecorderError::from_mikan_meta_missing_field(Cow::Borrowed("mikan_episode_id"))
})
.inspect_err(|error| {
tracing::warn!(error = %error);
})?;
let fansub_name = html
.select(
&Selector::parse(".bangumi-info a.magnet-link-wrap[href^='/Home/PublishGroup/']")
.unwrap(),
)
.next()
.map(extract_inner_text_from_element_ref)
.ok_or_else(|| RecorderError::from_mikan_meta_missing_field(Cow::Borrowed("fansub_name")))
.inspect_err(|error| {
tracing::warn!(error = %error);
})?;
let origin_poster_src = html.select(bangumi_poster_selector).next().and_then(|el| {
el.value()
.attr("data-src")
.and_then(|data_src| extract_image_src_from_str(data_src, &mikan_base_url))
.or_else(|| {
el.value().attr("style").and_then(|style| {
extract_background_image_src_from_style_attr(style, &mikan_base_url)
})
})
});
tracing::trace!(
bangumi_title,
mikan_bangumi_id,
episode_title,
mikan_episode_id,
origin_poster_src = origin_poster_src.as_ref().map(|url| url.as_str()),
fansub_name,
mikan_fansub_id,
"mikan episode meta extracted"
);
Ok(MikanEpisodeMeta {
mikan_bangumi_id,
mikan_fansub_id,
bangumi_title,
episode_title,
homepage: mikan_episode_homepage_url,
origin_poster_src,
fansub: fansub_name,
mikan_episode_id,
})
}
#[instrument(skip_all, fields(mikan_bangumi_homepage_url = mikan_bangumi_homepage_url.as_str()))]
pub async fn extract_mikan_bangumi_meta_from_bangumi_homepage(
http_client: &MikanClient,
mikan_bangumi_homepage_url: Url,
) -> Result<MikanBangumiMeta, RecorderError> {
let mikan_base_url = Url::parse(&mikan_bangumi_homepage_url.origin().unicode_serialization())?;
let content = fetch_html(http_client, mikan_bangumi_homepage_url.as_str()).await?;
let html = Html::parse_document(&content);
let bangumi_title_selector = &Selector::parse(".bangumi-title").unwrap();
let mikan_bangumi_id_selector =
&Selector::parse(".bangumi-title > .mikan-rss[data-original-title='RSS']").unwrap();
let bangumi_poster_selector = &Selector::parse(".bangumi-poster").unwrap();
let bangumi_title = html
.select(bangumi_title_selector)
.next()
.map(extract_inner_text_from_element_ref)
.ok_or_else(|| RecorderError::from_mikan_meta_missing_field(Cow::Borrowed("bangumi_title")))
.inspect_err(|error| tracing::warn!(error = %error))?;
let mikan_bangumi_id = html
.select(mikan_bangumi_id_selector)
.next()
.and_then(|el| el.value().attr("href"))
.and_then(|s| mikan_bangumi_homepage_url.join(s).ok())
.and_then(|rss_link_url| extract_mikan_bangumi_id_from_rss_link(&rss_link_url))
.map(
|MikanBangumiRssLink {
mikan_bangumi_id, ..
}| mikan_bangumi_id,
)
.ok_or_else(|| {
RecorderError::from_mikan_meta_missing_field(Cow::Borrowed("mikan_bangumi_id"))
})
.inspect_err(|error| tracing::error!(error = %error))?;
let origin_poster_src = html.select(bangumi_poster_selector).next().and_then(|el| {
el.value()
.attr("data-src")
.and_then(|data_src| extract_image_src_from_str(data_src, &mikan_base_url))
.or_else(|| {
el.value().attr("style").and_then(|style| {
extract_background_image_src_from_style_attr(style, &mikan_base_url)
})
})
});
let (mikan_fansub_id, fansub_name) = mikan_bangumi_homepage_url
.fragment()
.and_then(|id| {
html.select(
&Selector::parse(&format!("a.subgroup-name[data-anchor='#{}']", id)).unwrap(),
)
.next()
.map(extract_inner_text_from_element_ref)
.map(|fansub_name| (id.to_string(), fansub_name))
})
.unzip();
tracing::trace!(
bangumi_title,
mikan_bangumi_id,
origin_poster_src = origin_poster_src.as_ref().map(|url| url.as_str()),
fansub_name,
mikan_fansub_id,
"mikan bangumi meta extracted"
);
Ok(MikanBangumiMeta {
homepage: mikan_bangumi_homepage_url,
bangumi_title,
origin_poster_src,
mikan_bangumi_id,
fansub: fansub_name,
mikan_fansub_id,
})
}
#[instrument(skip_all, fields(my_bangumi_page_url, auth_secrecy = ?auth_secrecy, history = history.len()))]
pub fn extract_mikan_bangumis_meta_from_my_bangumi_page(
context: Arc<dyn AppContextTrait>,
my_bangumi_page_url: Url,
auth_secrecy: Option<MikanAuthSecrecy>,
history: &[Arc<RecorderResult<MikanBangumiMeta>>],
) -> impl Stream<Item = RecorderResult<MikanBangumiMeta>> {
try_stream! {
let http_client = &context.mikan().fork_with_auth(auth_secrecy.clone())?;
let mikan_base_url = Url::parse(&my_bangumi_page_url.origin().unicode_serialization())?;
let content = fetch_html(http_client, my_bangumi_page_url.clone()).await?;
let fansub_container_selector =
&Selector::parse(".js-expand_bangumi-subgroup.js-subscribed").unwrap();
let fansub_title_selector = &Selector::parse(".tag-res-name[title]").unwrap();
let fansub_id_selector =
&Selector::parse(".active[data-subtitlegroupid][data-bangumiid]").unwrap();
let bangumi_items = {
let html = Html::parse_document(&content);
let bangumi_container_selector = &Selector::parse(".sk-bangumi .an-ul>li").unwrap();
let bangumi_info_selector = &Selector::parse(".an-info a.an-text").unwrap();
let bangumi_poster_selector =
&Selector::parse("span[data-src][data-bangumiid], span[data-bangumiid][style]")
.unwrap();
html.select(bangumi_container_selector)
.filter_map(|bangumi_elem| {
let title_and_href_elem =
bangumi_elem.select(bangumi_info_selector).next();
let poster_elem = bangumi_elem.select(bangumi_poster_selector).next();
if let (Some(bangumi_home_page_url), Some(bangumi_title)) = (
title_and_href_elem.and_then(|elem| elem.attr("href")),
title_and_href_elem.and_then(|elem| elem.attr("title")),
) {
let origin_poster_src = poster_elem.and_then(|ele| {
ele.attr("data-src")
.and_then(|data_src| {
extract_image_src_from_str(data_src, &mikan_base_url)
})
.or_else(|| {
ele.attr("style").and_then(|style| {
extract_background_image_src_from_style_attr(
style,
&mikan_base_url,
)
})
})
});
let bangumi_title = bangumi_title.to_string();
let bangumi_home_page_url =
my_bangumi_page_url.join(bangumi_home_page_url).ok()?;
let MikanBangumiHomepage {
mikan_bangumi_id, ..
} = extract_mikan_bangumi_id_from_homepage(&bangumi_home_page_url)?;
if let Some(origin_poster_src) = origin_poster_src.as_ref() {
tracing::trace!(
origin_poster_src = origin_poster_src.as_str(),
bangumi_title,
mikan_bangumi_id,
"bangumi info extracted"
);
} else {
tracing::warn!(
bangumi_title,
mikan_bangumi_id,
"bangumi info extracted, but failed to extract poster_src"
);
}
let bangumi_expand_info_url = build_mikan_bangumi_expand_info_url(
mikan_base_url.clone(),
&mikan_bangumi_id,
);
Some((
bangumi_title,
mikan_bangumi_id,
bangumi_expand_info_url,
origin_poster_src,
))
} else {
None
}
})
.collect_vec()
};
for (idx, (bangumi_title, mikan_bangumi_id, bangumi_expand_info_url, origin_poster_src)) in
bangumi_items.iter().enumerate()
{
if history.get(idx).is_some() {
continue;
} else if let Some((fansub_name, mikan_fansub_id)) = {
let bangumi_expand_info_content =
fetch_html(http_client, bangumi_expand_info_url.clone()).await?;
let bangumi_expand_info_fragment =
Html::parse_fragment(&bangumi_expand_info_content);
bangumi_expand_info_fragment
.select(fansub_container_selector)
.next()
.and_then(|fansub_info| {
if let (Some(fansub_name), Some(mikan_fansub_id)) = (
fansub_info
.select(fansub_title_selector)
.next()
.and_then(|ele| ele.attr("title"))
.map(String::from),
fansub_info
.select(fansub_id_selector)
.next()
.and_then(|ele| ele.attr("data-subtitlegroupid"))
.map(String::from),
) {
Some((fansub_name, mikan_fansub_id))
} else {
None
}
})
} {
tracing::trace!(fansub_name, mikan_fansub_id, "subscribed fansub extracted");
let item = MikanBangumiMeta {
homepage: build_mikan_bangumi_homepage(
mikan_base_url.clone(),
mikan_bangumi_id,
Some(&mikan_fansub_id),
),
bangumi_title: bangumi_title.to_string(),
mikan_bangumi_id: mikan_bangumi_id.to_string(),
mikan_fansub_id: Some(mikan_fansub_id),
fansub: Some(fansub_name),
origin_poster_src: origin_poster_src.clone(),
};
yield item;
}
}
}
}
#[cfg(test)]
mod test {
#![allow(unused_variables)]
use futures::{TryStreamExt, pin_mut};
use http::header;
use rstest::{fixture, rstest};
use tracing::Level;
use url::Url;
use zune_image::{codecs::ImageFormat, image::Image};
use super::*;
use crate::test_utils::{
app::UnitTestAppContext, mikan::build_testing_mikan_client,
tracing::try_init_testing_tracing,
};
#[fixture]
fn before_each() {
try_init_testing_tracing(Level::INFO);
}
#[rstest]
#[tokio::test]
async fn test_extract_mikan_poster_from_src(before_each: ()) -> RecorderResult<()> {
let mut mikan_server = mockito::Server::new_async().await;
let mikan_base_url = Url::parse(&mikan_server.url())?;
let mikan_client = build_testing_mikan_client(mikan_base_url.clone()).await?;
let bangumi_poster_url = mikan_base_url.join("/images/Bangumi/202309/5ce9fed1.jpg")?;
let bangumi_poster_mock = mikan_server
.mock("GET", bangumi_poster_url.path())
.with_body_from_file("tests/resources/mikan/Bangumi-202309-5ce9fed1.jpg")
.create_async()
.await;
let bgm_poster =
extract_mikan_poster_meta_from_src(&mikan_client, bangumi_poster_url).await?;
bangumi_poster_mock.expect(1);
let u8_data = bgm_poster.poster_data.expect("should have poster data");
let image = Image::read(u8_data.to_vec(), Default::default());
assert!(
image.is_ok_and(|img| img
.metadata()
.get_image_format()
.is_some_and(|fmt| matches!(fmt, ImageFormat::JPEG))),
"should start with valid jpeg data magic number"
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn test_extract_mikan_episode(before_each: ()) -> RecorderResult<()> {
let mut mikan_server = mockito::Server::new_async().await;
let mikan_base_url = Url::parse(&mikan_server.url())?;
let mikan_client = build_testing_mikan_client(mikan_base_url.clone()).await?;
let episode_homepage_url =
mikan_base_url.join("/Home/Episode/475184dce83ea2b82902592a5ac3343f6d54b36a")?;
let episode_homepage_mock = mikan_server
.mock("GET", episode_homepage_url.path())
.with_body_from_file(
"tests/resources/mikan/Episode-475184dce83ea2b82902592a5ac3343f6d54b36a.htm",
)
.create_async()
.await;
let ep_meta = extract_mikan_episode_meta_from_episode_homepage(
&mikan_client,
episode_homepage_url.clone(),
)
.await?;
assert_eq!(ep_meta.homepage, episode_homepage_url);
assert_eq!(ep_meta.bangumi_title, "葬送的芙莉莲");
assert_eq!(
ep_meta
.origin_poster_src
.as_ref()
.map(|s| s.path().to_string()),
Some(String::from("/images/Bangumi/202309/5ce9fed1.jpg"))
);
assert_eq!(ep_meta.fansub, "LoliHouse");
assert_eq!(ep_meta.mikan_fansub_id, "370");
assert_eq!(ep_meta.mikan_bangumi_id, "3141");
Ok(())
}
#[rstest]
#[tokio::test]
async fn test_extract_mikan_bangumi_meta_from_bangumi_homepage(before_each: ()) -> RecorderResult<()> {
let mut mikan_server = mockito::Server::new_async().await;
let mikan_base_url = Url::parse(&mikan_server.url())?;
let mikan_client = build_testing_mikan_client(mikan_base_url.clone()).await?;
let bangumi_homepage_url = mikan_base_url.join("/Home/Bangumi/3416#370")?;
let bangumi_homepage_mock = mikan_server
.mock("GET", bangumi_homepage_url.path())
.with_body_from_file("tests/resources/mikan/Bangumi-3416-370.htm")
.create_async()
.await;
let bgm_meta = extract_mikan_bangumi_meta_from_bangumi_homepage(
&mikan_client,
bangumi_homepage_url.clone(),
)
.await?;
assert_eq!(bgm_meta.homepage, bangumi_homepage_url);
assert_eq!(bgm_meta.bangumi_title, "叹气的亡灵想隐退");
assert_eq!(
bgm_meta
.origin_poster_src
.as_ref()
.map(|s| s.path().to_string()),
Some(String::from("/images/Bangumi/202410/480ef127.jpg"))
);
assert_eq!(bgm_meta.fansub, Some(String::from("LoliHouse")));
assert_eq!(bgm_meta.mikan_fansub_id, Some(String::from("370")));
assert_eq!(bgm_meta.mikan_bangumi_id, "3416");
Ok(())
}
#[rstest]
#[tokio::test]
async fn test_extract_mikan_bangumis_meta_from_my_bangumi_page(before_each: ()) -> RecorderResult<()> {
let mut mikan_server = mockito::Server::new_async().await;
let mikan_base_url = Url::parse(&mikan_server.url())?;
let my_bangumi_page_url = mikan_base_url.join("/Home/MyBangumi")?;
let context = Arc::new(
UnitTestAppContext::builder()
.mikan(build_testing_mikan_client(mikan_base_url.clone()).await?)
.build(),
);
{
let my_bangumi_without_cookie_mock = mikan_server
.mock("GET", my_bangumi_page_url.path())
.match_header(header::COOKIE, mockito::Matcher::Missing)
.with_body_from_file("tests/resources/mikan/MyBangumi-noauth.htm")
.create_async()
.await;
let bangumi_metas = extract_mikan_bangumis_meta_from_my_bangumi_page(
context.clone(),
my_bangumi_page_url.clone(),
None,
&[],
);
pin_mut!(bangumi_metas);
let bangumi_metas = bangumi_metas.try_collect::<Vec<_>>().await?;
assert!(bangumi_metas.is_empty());
assert!(my_bangumi_without_cookie_mock.matched_async().await);
}
{
let my_bangumi_with_cookie_mock = mikan_server
.mock("GET", my_bangumi_page_url.path())
.match_header(
header::COOKIE,
mockito::Matcher::AllOf(vec![
mockito::Matcher::Regex(String::from(".*\\.AspNetCore\\.Antiforgery.*")),
mockito::Matcher::Regex(String::from(
".*\\.AspNetCore\\.Identity\\.Application.*",
)),
]),
)
.with_body_from_file("tests/resources/mikan/MyBangumi.htm")
.create_async()
.await;
let expand_bangumi_mock = mikan_server
.mock("GET", "/ExpandBangumi")
.match_query(mockito::Matcher::Any)
.with_body_from_file("tests/resources/mikan/ExpandBangumi.htm")
.create_async()
.await;
let auth_secrecy = Some(MikanAuthSecrecy {
cookie: String::from(
"mikan-announcement=1; .AspNetCore.Antiforgery.abc=abc; \
.AspNetCore.Identity.Application=abc; ",
),
user_agent: Some(String::from(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like \
Gecko) Chrome/133.0.0.0 Safari/537.36 Edg/133.0.0.0",
)),
});
let bangumi_metas = extract_mikan_bangumis_meta_from_my_bangumi_page(
context.clone(),
my_bangumi_page_url,
auth_secrecy,
&[],
);
pin_mut!(bangumi_metas);
let bangumi_metas = bangumi_metas.try_collect::<Vec<_>>().await?;
assert!(!bangumi_metas.is_empty());
assert!(bangumi_metas[0].origin_poster_src.is_some());
assert!(my_bangumi_with_cookie_mock.matched_async().await);
expand_bangumi_mock.expect(bangumi_metas.len());
}
Ok(())
}
}

View File

@ -101,19 +101,19 @@ fn title_body_pre_process(title_body: &str, fansub: Option<&str>) -> RecorderRes
raw = sub.replace_all(&raw, "").to_string(); raw = sub.replace_all(&raw, "").to_string();
} }
} }
if let Some(m) = MAIN_TITLE_PRE_PROCESS_BACKETS_RE.find(&raw) if let Some(m) = MAIN_TITLE_PRE_PROCESS_BACKETS_RE.find(&raw) {
&& m.len() as f32 > (raw.len() as f32) * 0.5 if m.len() as f32 > (raw.len() as f32) * 0.5 {
{ let mut raw1 = MAIN_TITLE_PRE_PROCESS_BACKETS_RE_SUB1
let mut raw1 = MAIN_TITLE_PRE_PROCESS_BACKETS_RE_SUB1 .replace(&raw, "")
.replace(&raw, "") .chars()
.chars() .collect_vec();
.collect_vec(); while let Some(ch) = raw1.pop() {
while let Some(ch) = raw1.pop() { if ch == ']' {
if ch == ']' { break;
break; }
} }
raw = raw1.into_iter().collect();
} }
raw = raw1.into_iter().collect();
} }
Ok(raw.to_string()) Ok(raw.to_string())
} }
@ -136,21 +136,23 @@ pub fn extract_season_from_title_body(title_body: &str) -> (String, Option<Strin
for s in seasons { for s in seasons {
season_raw = Some(s); season_raw = Some(s);
if let Some(m) = SEASON_EXTRACT_SEASON_EN_PREFIX_RE.find(s) if let Some(m) = SEASON_EXTRACT_SEASON_EN_PREFIX_RE.find(s) {
&& let Ok(s) = SEASON_EXTRACT_SEASON_ALL_RE if let Ok(s) = SEASON_EXTRACT_SEASON_ALL_RE
.replace_all(m.as_str(), "") .replace_all(m.as_str(), "")
.parse::<i32>() .parse::<i32>()
{ {
season = s; season = s;
break; break;
}
} }
if let Some(m) = SEASON_EXTRACT_SEASON_EN_NTH_RE.find(s) if let Some(m) = SEASON_EXTRACT_SEASON_EN_NTH_RE.find(s) {
&& let Some(s) = DIGIT_1PLUS_REG if let Some(s) = DIGIT_1PLUS_REG
.find(m.as_str()) .find(m.as_str())
.and_then(|s| s.as_str().parse::<i32>().ok()) .and_then(|s| s.as_str().parse::<i32>().ok())
{ {
season = s; season = s;
break; break;
}
} }
if let Some(m) = SEASON_EXTRACT_SEASON_ZH_PREFIX_RE.find(s) { if let Some(m) = SEASON_EXTRACT_SEASON_ZH_PREFIX_RE.find(s) {
if let Ok(s) = SEASON_EXTRACT_SEASON_ZH_PREFIX_SUB_RE if let Ok(s) = SEASON_EXTRACT_SEASON_ZH_PREFIX_SUB_RE

View File

@ -4,8 +4,11 @@ use async_graphql::dynamic::{ResolverContext, ValueAccessor};
use sea_orm::EntityTrait; use sea_orm::EntityTrait;
use seaography::{BuilderContext, FnGuard, GuardAction}; use seaography::{BuilderContext, FnGuard, GuardAction};
use super::util::{get_column_key, get_entity_key}; use super::util::get_entity_key;
use crate::auth::{AuthError, AuthUserInfo}; use crate::{
auth::{AuthError, AuthUserInfo},
graphql::util::get_column_key,
};
fn guard_data_object_accessor_with_subscriber_id( fn guard_data_object_accessor_with_subscriber_id(
value: ValueAccessor<'_>, value: ValueAccessor<'_>,
@ -47,20 +50,27 @@ fn guard_data_object_accessor_with_optional_subscriber_id(
} }
} }
pub fn guard_entity_with_subscriber_id<T>(_context: &BuilderContext, _column: &T::Column) -> FnGuard fn guard_filter_object_accessor_with_subscriber_id(
where value: ValueAccessor<'_>,
T: EntityTrait, column_name: &str,
<T as EntityTrait>::Model: Sync, subscriber_id: i32,
{ ) -> async_graphql::Result<()> {
Box::new(move |context: &ResolverContext| -> GuardAction { let obj = value.object()?;
match context.ctx.data::<AuthUserInfo>() { let subscriber_id_filter_input_value = obj.try_get(column_name)?;
Ok(_) => GuardAction::Allow,
Err(err) => GuardAction::Block(Some(err.message)), let subscriber_id_filter_input_obj = subscriber_id_filter_input_value.object()?;
}
}) let subscriber_id_value = subscriber_id_filter_input_obj.try_get("eq")?;
let id = subscriber_id_value.i64()?;
if id == subscriber_id as i64 {
Ok(())
} else {
Err(async_graphql::Error::new("subscriber not match"))
}
} }
pub fn guard_field_with_subscriber_id<T>(context: &BuilderContext, column: &T::Column) -> FnGuard pub fn guard_entity_with_subscriber_id<T>(context: &BuilderContext, column: &T::Column) -> FnGuard
where where
T: EntityTrait, T: EntityTrait,
<T as EntityTrait>::Model: Sync, <T as EntityTrait>::Model: Sync,
@ -85,90 +95,148 @@ where
)); ));
let entity_create_batch_mutation_data_field_name = let entity_create_batch_mutation_data_field_name =
Arc::new(context.entity_create_batch_mutation.data_field.clone()); Arc::new(context.entity_create_batch_mutation.data_field.clone());
let entity_delete_mutation_field_name = Arc::new(format!(
"{}{}",
entity_name,
context.entity_delete_mutation.mutation_suffix.clone()
));
let entity_delete_mutation_filter_field_name =
Arc::new(context.entity_delete_mutation.filter_field.clone());
let entity_update_mutation_field_name = Arc::new(format!( let entity_update_mutation_field_name = Arc::new(format!(
"{}{}", "{}{}",
entity_name, context.entity_update_mutation.mutation_suffix entity_name, context.entity_update_mutation.mutation_suffix
)); ));
let entity_update_mutation_filter_field_name =
Arc::new(context.entity_update_mutation.filter_field.clone());
let entity_update_mutation_data_field_name = let entity_update_mutation_data_field_name =
Arc::new(context.entity_update_mutation.data_field.clone()); Arc::new(context.entity_update_mutation.data_field.clone());
let entity_query_field_name = Arc::new(entity_name);
let entity_query_filter_field_name = Arc::new(context.entity_query_field.filters.clone());
Box::new(move |context: &ResolverContext| -> GuardAction { Box::new(move |context: &ResolverContext| -> GuardAction {
match context.ctx.data::<AuthUserInfo>() { match context.ctx.data::<AuthUserInfo>() {
Ok(user_info) => { Ok(user_info) => {
let subscriber_id = user_info.subscriber_auth.subscriber_id; let subscriber_id = user_info.subscriber_auth.subscriber_id;
let validation_result = match context.field().name() { let validation_result = match context.field().name() {
field if field == entity_create_one_mutation_field_name.as_str() => { field if field == entity_create_one_mutation_field_name.as_str() => context
if let Some(data_value) = context .args
.args .try_get(&entity_create_one_mutation_data_field_name)
.get(&entity_create_one_mutation_data_field_name) .and_then(|data_value| {
{
guard_data_object_accessor_with_subscriber_id( guard_data_object_accessor_with_subscriber_id(
data_value, data_value,
&column_name, &column_name,
subscriber_id, subscriber_id,
) )
.map_err(|inner_error| { })
AuthError::from_graphql_subscribe_id_guard( .map_err(|inner_error| {
inner_error, AuthError::from_graphql_subscribe_id_guard(
context, inner_error,
&entity_create_one_mutation_data_field_name, context,
&column_name, &entity_create_one_mutation_data_field_name,
) &column_name,
}) )
} else { }),
Ok(()) field if field == entity_create_batch_mutation_field_name.as_str() => context
} .args
} .try_get(&entity_create_batch_mutation_data_field_name)
field if field == entity_create_batch_mutation_field_name.as_str() => { .and_then(|data_value| {
if let Some(data_value) = context data_value.list().and_then(|data_list| {
.args data_list.iter().try_for_each(|data_item_value| {
.get(&entity_create_batch_mutation_data_field_name) guard_data_object_accessor_with_subscriber_id(
{ data_item_value,
data_value
.list()
.and_then(|data_list| {
data_list.iter().try_for_each(|data_item_value| {
guard_data_object_accessor_with_optional_subscriber_id(
data_item_value,
&column_name,
subscriber_id,
)
})
})
.map_err(|inner_error| {
AuthError::from_graphql_subscribe_id_guard(
inner_error,
context,
&entity_create_batch_mutation_data_field_name,
&column_name, &column_name,
subscriber_id,
) )
}) })
} else { })
Ok(()) })
} .map_err(|inner_error| {
} AuthError::from_graphql_subscribe_id_guard(
field if field == entity_update_mutation_field_name.as_str() => { inner_error,
if let Some(data_value) = context,
context.args.get(&entity_update_mutation_data_field_name) &entity_create_batch_mutation_data_field_name,
{ &column_name,
guard_data_object_accessor_with_optional_subscriber_id( )
data_value, }),
field if field == entity_delete_mutation_field_name.as_str() => context
.args
.try_get(&entity_delete_mutation_filter_field_name)
.and_then(|filter_value| {
guard_filter_object_accessor_with_subscriber_id(
filter_value,
&column_name, &column_name,
subscriber_id, subscriber_id,
) )
.map_err(|inner_error| { })
AuthError::from_graphql_subscribe_id_guard( .map_err(|inner_error| {
inner_error, AuthError::from_graphql_subscribe_id_guard(
context, inner_error,
&entity_update_mutation_data_field_name, context,
&column_name, &entity_delete_mutation_filter_field_name,
) &column_name,
}) )
} else { }),
Ok(()) field if field == entity_update_mutation_field_name.as_str() => context
} .args
} .try_get(&entity_update_mutation_filter_field_name)
_ => Ok(()), .and_then(|filter_value| {
guard_filter_object_accessor_with_subscriber_id(
filter_value,
&column_name,
subscriber_id,
)
})
.map_err(|inner_error| {
AuthError::from_graphql_subscribe_id_guard(
inner_error,
context,
&entity_update_mutation_filter_field_name,
&column_name,
)
})
.and_then(|_| {
match context.args.get(&entity_update_mutation_data_field_name) {
Some(data_value) => {
guard_data_object_accessor_with_optional_subscriber_id(
data_value,
&column_name,
subscriber_id,
)
.map_err(|inner_error| {
AuthError::from_graphql_subscribe_id_guard(
inner_error,
context,
&entity_update_mutation_data_field_name,
&column_name,
)
})
}
None => Ok(()),
}
}),
field if field == entity_query_field_name.as_str() => context
.args
.try_get(&entity_query_filter_field_name)
.and_then(|filter_value| {
guard_filter_object_accessor_with_subscriber_id(
filter_value,
&column_name,
subscriber_id,
)
})
.map_err(|inner_error| {
AuthError::from_graphql_subscribe_id_guard(
inner_error,
context,
&entity_query_filter_field_name,
&column_name,
)
}),
field => Err(AuthError::from_graphql_subscribe_id_guard(
async_graphql::Error::new("unsupport graphql field"),
context,
field,
"",
)),
}; };
match validation_result { match validation_result {
Ok(_) => GuardAction::Allow, Ok(_) => GuardAction::Allow,

View File

@ -4,7 +4,6 @@ pub mod guard;
pub mod schema_root; pub mod schema_root;
pub mod service; pub mod service;
pub mod subscriptions; pub mod subscriptions;
pub mod transformer;
pub mod util; pub mod util;
pub use config::GraphQLConfig; pub use config::GraphQLConfig;

View File

@ -3,16 +3,13 @@ use once_cell::sync::OnceCell;
use sea_orm::{DatabaseConnection, EntityTrait, Iterable}; use sea_orm::{DatabaseConnection, EntityTrait, Iterable};
use seaography::{Builder, BuilderContext, FilterType, FilterTypesMapHelper}; use seaography::{Builder, BuilderContext, FilterType, FilterTypesMapHelper};
use super::transformer::{filter_condition_transformer, mutation_input_object_transformer}; use super::{
use crate::graphql::{ filter::{SUBSCRIBER_ID_FILTER_INFO, subscriber_id_condition_function},
filter::{
SUBSCRIBER_ID_FILTER_INFO, init_custom_filter_info, subscriber_id_condition_function,
},
guard::{guard_entity_with_subscriber_id, guard_field_with_subscriber_id},
util::{get_entity_column_key, get_entity_key}, util::{get_entity_column_key, get_entity_key},
}; };
use crate::graphql::{filter::init_custom_filter_info, guard::guard_entity_with_subscriber_id};
pub static CONTEXT: OnceCell<BuilderContext> = OnceCell::new(); static CONTEXT: OnceCell<BuilderContext> = OnceCell::new();
fn restrict_filter_input_for_entity<T>( fn restrict_filter_input_for_entity<T>(
context: &mut BuilderContext, context: &mut BuilderContext,
@ -34,13 +31,9 @@ where
let entity_key = get_entity_key::<T>(context); let entity_key = get_entity_key::<T>(context);
let entity_column_key = get_entity_column_key::<T>(context, column); let entity_column_key = get_entity_column_key::<T>(context, column);
context.guards.entity_guards.insert( context.guards.entity_guards.insert(
entity_key.clone(), entity_key,
guard_entity_with_subscriber_id::<T>(context, column), guard_entity_with_subscriber_id::<T>(context, column),
); );
context.guards.field_guards.insert(
entity_column_key.clone(),
guard_field_with_subscriber_id::<T>(context, column),
);
context.filter_types.overwrites.insert( context.filter_types.overwrites.insert(
entity_column_key.clone(), entity_column_key.clone(),
Some(FilterType::Custom( Some(FilterType::Custom(
@ -48,25 +41,9 @@ where
)), )),
); );
context.filter_types.condition_functions.insert( context.filter_types.condition_functions.insert(
entity_column_key.clone(), entity_column_key,
subscriber_id_condition_function::<T>(context, column), subscriber_id_condition_function::<T>(context, column),
); );
context.transformers.filter_conditions_transformers.insert(
entity_key.clone(),
filter_condition_transformer::<T>(context, column),
);
context
.transformers
.mutation_input_object_transformers
.insert(
entity_key,
mutation_input_object_transformer::<T>(context, column),
);
context
.entity_input
.insert_skips
.push(entity_column_key.clone());
context.entity_input.update_skips.push(entity_column_key);
} }
pub fn schema( pub fn schema(
@ -176,6 +153,7 @@ pub fn schema(
}; };
schema schema
.data(database) .data(database)
// .extension(GraphqlAuthExtension)
.finish() .finish()
.inspect_err(|e| tracing::error!(e = ?e)) .inspect_err(|e| tracing::error!(e = ?e))
} }

View File

@ -1,83 +0,0 @@
use std::{collections::BTreeMap, sync::Arc};
use async_graphql::dynamic::ResolverContext;
use sea_orm::{ColumnTrait, Condition, EntityTrait, Value};
use seaography::{BuilderContext, FnFilterConditionsTransformer, FnMutationInputObjectTransformer};
use super::util::{get_column_key, get_entity_key};
use crate::auth::AuthUserInfo;
pub fn filter_condition_transformer<T>(
_context: &BuilderContext,
column: &T::Column,
) -> FnFilterConditionsTransformer
where
T: EntityTrait,
<T as EntityTrait>::Model: Sync,
{
let column = *column;
Box::new(
move |context: &ResolverContext, condition: Condition| -> Condition {
match context.ctx.data::<AuthUserInfo>() {
Ok(user_info) => {
let subscriber_id = user_info.subscriber_auth.subscriber_id;
condition.add(column.eq(subscriber_id))
}
Err(err) => unreachable!("auth user info must be guarded: {:?}", err),
}
},
)
}
pub fn mutation_input_object_transformer<T>(
context: &BuilderContext,
column: &T::Column,
) -> FnMutationInputObjectTransformer
where
T: EntityTrait,
<T as EntityTrait>::Model: Sync,
{
let entity_key = get_entity_key::<T>(context);
let entity_name = context.entity_query_field.type_name.as_ref()(&entity_key);
let column_key = get_column_key::<T>(context, column);
let column_name = Arc::new(context.entity_object.column_name.as_ref()(
&entity_key,
&column_key,
));
let entity_create_one_mutation_field_name = Arc::new(format!(
"{}{}",
entity_name, context.entity_create_one_mutation.mutation_suffix
));
let entity_create_batch_mutation_field_name = Arc::new(format!(
"{}{}",
entity_name,
context.entity_create_batch_mutation.mutation_suffix.clone()
));
Box::new(
move |context: &ResolverContext,
mut input: BTreeMap<String, Value>|
-> BTreeMap<String, Value> {
let field_name = context.field().name();
if field_name == entity_create_one_mutation_field_name.as_str()
|| field_name == entity_create_batch_mutation_field_name.as_str()
{
match context.ctx.data::<AuthUserInfo>() {
Ok(user_info) => {
let subscriber_id = user_info.subscriber_auth.subscriber_id;
let value = input.get_mut(column_name.as_str());
if value.is_none() {
input.insert(
column_name.as_str().to_string(),
Value::Int(Some(subscriber_id)),
);
}
input
}
Err(err) => unreachable!("auth user info must be guarded: {:?}", err),
}
} else {
input
}
},
)
}

View File

@ -1,5 +1,5 @@
#![feature( #![feature(
duration_constructors_lite, duration_constructors,
assert_matches, assert_matches,
unboxed_closures, unboxed_closures,
impl_trait_in_bindings, impl_trait_in_bindings,
@ -14,7 +14,6 @@ pub use downloader;
pub mod app; pub mod app;
pub mod auth; pub mod auth;
pub mod cache; pub mod cache;
pub mod crypto;
pub mod database; pub mod database;
pub mod errors; pub mod errors;
pub mod extract; pub mod extract;

View File

@ -77,62 +77,62 @@ impl LoggerService {
pub async fn from_config(config: LoggerConfig) -> RecorderResult<Self> { pub async fn from_config(config: LoggerConfig) -> RecorderResult<Self> {
let mut layers: Vec<Box<dyn Layer<Registry> + Sync + Send>> = Vec::new(); let mut layers: Vec<Box<dyn Layer<Registry> + Sync + Send>> = Vec::new();
if let Some(file_appender_config) = config.file_appender.as_ref() if let Some(file_appender_config) = config.file_appender.as_ref() {
&& file_appender_config.enable if file_appender_config.enable {
{ let dir = file_appender_config
let dir = file_appender_config .dir
.dir .as_ref()
.as_ref() .map_or_else(|| "./logs".to_string(), ToString::to_string);
.map_or_else(|| "./logs".to_string(), ToString::to_string);
let mut rolling_builder = tracing_appender::rolling::Builder::default() let mut rolling_builder = tracing_appender::rolling::Builder::default()
.max_log_files(file_appender_config.max_log_files); .max_log_files(file_appender_config.max_log_files);
rolling_builder = match file_appender_config.rotation { rolling_builder = match file_appender_config.rotation {
LogRotation::Minutely => { LogRotation::Minutely => {
rolling_builder.rotation(tracing_appender::rolling::Rotation::MINUTELY) rolling_builder.rotation(tracing_appender::rolling::Rotation::MINUTELY)
} }
LogRotation::Hourly => { LogRotation::Hourly => {
rolling_builder.rotation(tracing_appender::rolling::Rotation::HOURLY) rolling_builder.rotation(tracing_appender::rolling::Rotation::HOURLY)
} }
LogRotation::Daily => { LogRotation::Daily => {
rolling_builder.rotation(tracing_appender::rolling::Rotation::DAILY) rolling_builder.rotation(tracing_appender::rolling::Rotation::DAILY)
} }
LogRotation::Never => { LogRotation::Never => {
rolling_builder.rotation(tracing_appender::rolling::Rotation::NEVER) rolling_builder.rotation(tracing_appender::rolling::Rotation::NEVER)
} }
};
let file_appender = rolling_builder
.filename_prefix(
file_appender_config
.filename_prefix
.as_ref()
.map_or_else(String::new, ToString::to_string),
)
.filename_suffix(
file_appender_config
.filename_suffix
.as_ref()
.map_or_else(String::new, ToString::to_string),
)
.build(dir)?;
let file_appender_layer = if file_appender_config.non_blocking {
let (non_blocking_file_appender, work_guard) =
tracing_appender::non_blocking(file_appender);
if NONBLOCKING_WORK_GUARD_KEEP.set(work_guard).is_err() {
whatever!("cannot lock for appender");
}; };
Self::init_layer(
non_blocking_file_appender, let file_appender = rolling_builder
&file_appender_config.format, .filename_prefix(
false, file_appender_config
) .filename_prefix
} else { .as_ref()
Self::init_layer(file_appender, &file_appender_config.format, false) .map_or_else(String::new, ToString::to_string),
}; )
layers.push(file_appender_layer); .filename_suffix(
file_appender_config
.filename_suffix
.as_ref()
.map_or_else(String::new, ToString::to_string),
)
.build(dir)?;
let file_appender_layer = if file_appender_config.non_blocking {
let (non_blocking_file_appender, work_guard) =
tracing_appender::non_blocking(file_appender);
if NONBLOCKING_WORK_GUARD_KEEP.set(work_guard).is_err() {
whatever!("cannot lock for appender");
};
Self::init_layer(
non_blocking_file_appender,
&file_appender_config.format,
false,
)
} else {
Self::init_layer(file_appender, &file_appender_config.format, false)
};
layers.push(file_appender_layer);
}
} }
if config.enable { if config.enable {

View File

@ -2,10 +2,7 @@ use std::collections::HashSet;
use async_trait::async_trait; use async_trait::async_trait;
use sea_orm::{DeriveIden, Statement}; use sea_orm::{DeriveIden, Statement};
use sea_orm_migration::{ use sea_orm_migration::prelude::{extension::postgres::IntoTypeRef, *};
prelude::{extension::postgres::IntoTypeRef, *},
schema::timestamp_with_time_zone,
};
use crate::migrations::extension::postgres::Type; use crate::migrations::extension::postgres::Type;
@ -33,7 +30,6 @@ pub enum Subscriptions {
Category, Category,
SourceUrl, SourceUrl,
Enabled, Enabled,
CredentialId,
} }
#[derive(DeriveIden)] #[derive(DeriveIden)]
@ -138,18 +134,6 @@ pub enum Auth {
AuthType, AuthType,
} }
#[derive(DeriveIden)]
pub enum Credential3rd {
Table,
Id,
SubscriberId,
CredentialType,
Cookies,
Username,
Password,
UserAgent,
}
macro_rules! create_postgres_enum_for_active_enum { macro_rules! create_postgres_enum_for_active_enum {
($manager: expr, $active_enum: expr, $($enum_value:expr),+) => { ($manager: expr, $active_enum: expr, $($enum_value:expr),+) => {
{ {
@ -160,17 +144,6 @@ macro_rules! create_postgres_enum_for_active_enum {
}; };
} }
pub fn timestamps_z(t: TableCreateStatement) -> TableCreateStatement {
let mut t = t;
t.col(timestamp_with_time_zone(GeneralIds::CreatedAt).default(Expr::current_timestamp()))
.col(timestamp_with_time_zone(GeneralIds::UpdatedAt).default(Expr::current_timestamp()))
.take()
}
pub fn table_auto_z<T: IntoIden + 'static>(name: T) -> TableCreateStatement {
timestamps_z(Table::create().table(name).if_not_exists().take())
}
#[async_trait] #[async_trait]
pub trait CustomSchemaManagerExt { pub trait CustomSchemaManagerExt {
async fn create_postgres_auto_update_ts_fn(&self, col_name: &str) -> Result<(), DbErr>; async fn create_postgres_auto_update_ts_fn(&self, col_name: &str) -> Result<(), DbErr>;

View File

@ -3,7 +3,7 @@ use sea_orm_migration::{prelude::*, schema::*};
use super::defs::{ use super::defs::{
Bangumi, CustomSchemaManagerExt, Episodes, GeneralIds, Subscribers, SubscriptionBangumi, Bangumi, CustomSchemaManagerExt, Episodes, GeneralIds, Subscribers, SubscriptionBangumi,
SubscriptionEpisode, Subscriptions, table_auto_z, SubscriptionEpisode, Subscriptions,
}; };
use crate::models::{ use crate::models::{
subscribers::SEED_SUBSCRIBER, subscribers::SEED_SUBSCRIBER,
@ -22,7 +22,7 @@ impl MigrationTrait for Migration {
manager manager
.create_table( .create_table(
table_auto_z(Subscribers::Table) table_auto(Subscribers::Table)
.col(pk_auto(Subscribers::Id)) .col(pk_auto(Subscribers::Id))
.col(string(Subscribers::DisplayName)) .col(string(Subscribers::DisplayName))
.col(json_binary_null(Subscribers::BangumiConf)) .col(json_binary_null(Subscribers::BangumiConf))
@ -57,7 +57,7 @@ impl MigrationTrait for Migration {
manager manager
.create_table( .create_table(
table_auto_z(Subscriptions::Table) table_auto(Subscriptions::Table)
.col(pk_auto(Subscriptions::Id)) .col(pk_auto(Subscriptions::Id))
.col(string(Subscriptions::DisplayName)) .col(string(Subscriptions::DisplayName))
.col(integer(Subscriptions::SubscriberId)) .col(integer(Subscriptions::SubscriberId))
@ -89,7 +89,7 @@ impl MigrationTrait for Migration {
manager manager
.create_table( .create_table(
table_auto_z(Bangumi::Table) table_auto(Bangumi::Table)
.col(pk_auto(Bangumi::Id)) .col(pk_auto(Bangumi::Id))
.col(text_null(Bangumi::MikanBangumiId)) .col(text_null(Bangumi::MikanBangumiId))
.col(integer(Bangumi::SubscriberId)) .col(integer(Bangumi::SubscriberId))
@ -156,7 +156,7 @@ impl MigrationTrait for Migration {
manager manager
.create_table( .create_table(
table_auto_z(SubscriptionBangumi::Table) table_auto(SubscriptionBangumi::Table)
.col(pk_auto(SubscriptionBangumi::Id)) .col(pk_auto(SubscriptionBangumi::Id))
.col(integer(SubscriptionBangumi::SubscriberId)) .col(integer(SubscriptionBangumi::SubscriberId))
.col(integer(SubscriptionBangumi::SubscriptionId)) .col(integer(SubscriptionBangumi::SubscriptionId))
@ -206,7 +206,7 @@ impl MigrationTrait for Migration {
manager manager
.create_table( .create_table(
table_auto_z(Episodes::Table) table_auto(Episodes::Table)
.col(pk_auto(Episodes::Id)) .col(pk_auto(Episodes::Id))
.col(text_null(Episodes::MikanEpisodeId)) .col(text_null(Episodes::MikanEpisodeId))
.col(text(Episodes::RawName)) .col(text(Episodes::RawName))
@ -275,7 +275,7 @@ impl MigrationTrait for Migration {
manager manager
.create_table( .create_table(
table_auto_z(SubscriptionEpisode::Table) table_auto(SubscriptionEpisode::Table)
.col(pk_auto(SubscriptionEpisode::Id)) .col(pk_auto(SubscriptionEpisode::Id))
.col(integer(SubscriptionEpisode::SubscriptionId)) .col(integer(SubscriptionEpisode::SubscriptionId))
.col(integer(SubscriptionEpisode::EpisodeId)) .col(integer(SubscriptionEpisode::EpisodeId))

View File

@ -23,7 +23,7 @@ impl MigrationTrait for Migration {
manager manager
.create_table( .create_table(
table_auto_z(Downloaders::Table) table_auto(Downloaders::Table)
.col(pk_auto(Downloaders::Id)) .col(pk_auto(Downloaders::Id))
.col(text(Downloaders::Endpoint)) .col(text(Downloaders::Endpoint))
.col(string_null(Downloaders::Username)) .col(string_null(Downloaders::Username))
@ -78,7 +78,7 @@ impl MigrationTrait for Migration {
manager manager
.create_table( .create_table(
table_auto_z(Downloads::Table) table_auto(Downloads::Table)
.col(pk_auto(Downloads::Id)) .col(pk_auto(Downloads::Id))
.col(string(Downloads::RawName)) .col(string(Downloads::RawName))
.col(string(Downloads::DisplayName)) .col(string(Downloads::DisplayName))

View File

@ -1,6 +1,5 @@
use sea_orm_migration::{prelude::*, schema::*}; use sea_orm_migration::{prelude::*, schema::*};
use super::defs::table_auto_z;
use crate::{ use crate::{
migrations::defs::{CustomSchemaManagerExt, Downloaders, GeneralIds, Subscribers}, migrations::defs::{CustomSchemaManagerExt, Downloaders, GeneralIds, Subscribers},
models::downloaders::{DownloaderCategory, DownloaderCategoryEnum}, models::downloaders::{DownloaderCategory, DownloaderCategoryEnum},
@ -21,7 +20,7 @@ impl MigrationTrait for Migration {
manager manager
.create_table( .create_table(
table_auto_z(Downloaders::Table) table_auto(Downloaders::Table)
.col(pk_auto(Downloaders::Id)) .col(pk_auto(Downloaders::Id))
.col(text(Downloaders::Endpoint)) .col(text(Downloaders::Endpoint))
.col(string_null(Downloaders::Username)) .col(string_null(Downloaders::Username))

View File

@ -1,7 +1,7 @@
use async_trait::async_trait; use async_trait::async_trait;
use sea_orm_migration::{prelude::*, schema::*}; use sea_orm_migration::{prelude::*, schema::*};
use super::defs::{Auth, table_auto_z}; use super::defs::Auth;
use crate::{ use crate::{
migrations::defs::{CustomSchemaManagerExt, GeneralIds, Subscribers}, migrations::defs::{CustomSchemaManagerExt, GeneralIds, Subscribers},
models::{ models::{
@ -26,7 +26,7 @@ impl MigrationTrait for Migration {
manager manager
.create_table( .create_table(
table_auto_z(Auth::Table) table_auto(Auth::Table)
.col(pk_auto(Auth::Id)) .col(pk_auto(Auth::Id))
.col(text(Auth::Pid)) .col(text(Auth::Pid))
.col(enumeration( .col(enumeration(

View File

@ -1,107 +0,0 @@
use async_trait::async_trait;
use sea_orm_migration::{
prelude::*,
schema::{string_null, *},
};
use super::defs::{CustomSchemaManagerExt, GeneralIds, table_auto_z};
use crate::{
migrations::defs::{Credential3rd, Subscribers, Subscriptions},
models::credential_3rd::{Credential3rdType, Credential3rdTypeEnum},
};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
create_postgres_enum_for_active_enum!(
manager,
Credential3rdTypeEnum,
Credential3rdType::Mikan
)
.await?;
manager
.create_table(
table_auto_z(Credential3rd::Table)
.col(pk_auto(Credential3rd::Id))
.col(integer(Credential3rd::SubscriberId))
.col(string(Credential3rd::CredentialType))
.col(string_null(Credential3rd::Cookies))
.col(string_null(Credential3rd::Username))
.col(string_null(Credential3rd::Password))
.col(string_null(Credential3rd::UserAgent))
.foreign_key(
ForeignKey::create()
.name("fk_credential_3rd_subscriber_id")
.from(Credential3rd::Table, Credential3rd::SubscriberId)
.to(Subscribers::Table, Subscribers::Id)
.on_update(ForeignKeyAction::Cascade)
.on_delete(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_credential_3rd_credential_type")
.table(Credential3rd::Table)
.col(Credential3rd::CredentialType)
.to_owned(),
)
.await?;
manager
.create_postgres_auto_update_ts_trigger_for_col(
Credential3rd::Table,
GeneralIds::UpdatedAt,
)
.await?;
manager
.alter_table(
Table::alter()
.table(Subscriptions::Table)
.add_column_if_not_exists(integer_null(Subscriptions::CredentialId))
.add_foreign_key(
TableForeignKey::new()
.name("fk_subscriptions_credential_id")
.from_tbl(Subscriptions::Table)
.from_col(Subscriptions::CredentialId)
.to_tbl(Credential3rd::Table)
.to_col(Credential3rd::Id)
.on_update(ForeignKeyAction::Cascade)
.on_delete(ForeignKeyAction::SetNull),
)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Subscriptions::Table)
.drop_column(Subscriptions::CredentialId)
.to_owned(),
)
.await?;
manager
.drop_table(Table::drop().table(Credential3rd::Table).to_owned())
.await?;
manager
.drop_postgres_enum_for_active_enum(Credential3rdTypeEnum)
.await?;
Ok(())
}
}

View File

@ -7,7 +7,6 @@ pub mod m20220101_000001_init;
pub mod m20240224_082543_add_downloads; pub mod m20240224_082543_add_downloads;
pub mod m20240225_060853_subscriber_add_downloader; pub mod m20240225_060853_subscriber_add_downloader;
pub mod m20241231_000001_auth; pub mod m20241231_000001_auth;
pub mod m20250501_021523_credential_3rd;
pub struct Migrator; pub struct Migrator;
@ -19,7 +18,6 @@ impl MigratorTrait for Migrator {
Box::new(m20240224_082543_add_downloads::Migration), Box::new(m20240224_082543_add_downloads::Migration),
Box::new(m20240225_060853_subscriber_add_downloader::Migration), Box::new(m20240225_060853_subscriber_add_downloader::Migration),
Box::new(m20241231_000001_auth::Migration), Box::new(m20241231_000001_auth::Migration),
Box::new(m20250501_021523_credential_3rd::Migration),
] ]
} }
} }

View File

@ -24,9 +24,9 @@ pub enum AuthType {
#[sea_orm(table_name = "auth")] #[sea_orm(table_name = "auth")]
pub struct Model { pub struct Model {
#[sea_orm(default_expr = "Expr::current_timestamp()")] #[sea_orm(default_expr = "Expr::current_timestamp()")]
pub created_at: DateTimeUtc, pub created_at: DateTime,
#[sea_orm(default_expr = "Expr::current_timestamp()")] #[sea_orm(default_expr = "Expr::current_timestamp()")]
pub updated_at: DateTimeUtc, pub updated_at: DateTime,
#[sea_orm(primary_key)] #[sea_orm(primary_key)]
pub id: i32, pub id: i32,
#[sea_orm(unique)] #[sea_orm(unique)]

View File

@ -30,9 +30,9 @@ pub struct BangumiExtra {
#[sea_orm(table_name = "bangumi")] #[sea_orm(table_name = "bangumi")]
pub struct Model { pub struct Model {
#[sea_orm(default_expr = "Expr::current_timestamp()")] #[sea_orm(default_expr = "Expr::current_timestamp()")]
pub created_at: DateTimeUtc, pub created_at: DateTime,
#[sea_orm(default_expr = "Expr::current_timestamp()")] #[sea_orm(default_expr = "Expr::current_timestamp()")]
pub updated_at: DateTimeUtc, pub updated_at: DateTime,
#[sea_orm(primary_key)] #[sea_orm(primary_key)]
pub id: i32, pub id: i32,
pub mikan_bangumi_id: Option<String>, pub mikan_bangumi_id: Option<String>,

View File

@ -1,143 +0,0 @@
use std::sync::Arc;
use async_trait::async_trait;
use sea_orm::{ActiveValue, prelude::*};
use serde::{Deserialize, Serialize};
use crate::{
app::AppContextTrait,
crypto::UserPassCredential,
errors::{RecorderError, RecorderResult},
};
#[derive(
Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, DeriveDisplay, Serialize, Deserialize,
)]
#[sea_orm(
rs_type = "String",
db_type = "Enum",
enum_name = "credential_3rd_type"
)]
pub enum Credential3rdType {
#[sea_orm(string_value = "mikan")]
Mikan,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, DeriveEntityModel)]
#[sea_orm(table_name = "credential3rd")]
pub struct Model {
#[sea_orm(default_expr = "Expr::current_timestamp()")]
pub created_at: DateTimeUtc,
#[sea_orm(default_expr = "Expr::current_timestamp()")]
pub updated_at: DateTimeUtc,
#[sea_orm(primary_key)]
pub id: i32,
pub subscriber_id: i32,
pub credential_type: Credential3rdType,
pub cookies: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
pub user_agent: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::subscribers::Entity",
from = "Column::SubscriberId",
to = "super::subscribers::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
Subscriber,
#[sea_orm(has_many = "super::subscriptions::Entity")]
Subscription,
}
impl Related<super::subscribers::Entity> for Entity {
fn to() -> RelationDef {
Relation::Subscriber.def()
}
}
impl Related<super::subscriptions::Entity> for Entity {
fn to() -> RelationDef {
Relation::Subscription.def()
}
}
#[async_trait]
impl ActiveModelBehavior for ActiveModel {}
impl ActiveModel {
pub async fn try_encrypt(mut self, ctx: Arc<dyn AppContextTrait>) -> RecorderResult<Self> {
let crypto = ctx.crypto();
if let ActiveValue::Set(Some(username)) = self.username {
let username_enc = crypto.encrypt_credentials(&username)?;
self.username = ActiveValue::Set(Some(username_enc));
}
if let ActiveValue::Set(Some(password)) = self.password {
let password_enc = crypto.encrypt_credentials(&password)?;
self.password = ActiveValue::Set(Some(password_enc));
}
if let ActiveValue::Set(Some(cookies)) = self.cookies {
let cookies_enc = crypto.encrypt_credentials(&cookies)?;
self.cookies = ActiveValue::Set(Some(cookies_enc));
}
Ok(self)
}
}
impl Model {
pub async fn find_by_id(
ctx: Arc<dyn AppContextTrait>,
id: i32,
) -> RecorderResult<Option<Self>> {
let db = ctx.db();
let credential = Entity::find_by_id(id).one(db).await?;
Ok(credential)
}
pub fn try_into_userpass_credential(
self,
ctx: Arc<dyn AppContextTrait>,
) -> RecorderResult<UserPassCredential> {
let crypto = ctx.crypto();
let username_enc = self
.username
.ok_or_else(|| RecorderError::Credential3rdError {
message: "UserPassCredential username is required".to_string(),
source: None.into(),
})?;
let username: String = crypto.decrypt_credentials(&username_enc)?;
let password_enc = self
.password
.ok_or_else(|| RecorderError::Credential3rdError {
message: "UserPassCredential password is required".to_string(),
source: None.into(),
})?;
let password: String = crypto.decrypt_credentials(&password_enc)?;
let cookies: Option<String> = if let Some(cookies_enc) = self.cookies {
let cookies = crypto.decrypt_credentials(&cookies_enc)?;
Some(cookies)
} else {
None
};
Ok(UserPassCredential {
username,
password,
cookies,
user_agent: self.user_agent,
})
}
}

View File

@ -23,9 +23,9 @@ pub enum DownloaderCategory {
#[sea_orm(table_name = "downloaders")] #[sea_orm(table_name = "downloaders")]
pub struct Model { pub struct Model {
#[sea_orm(default_expr = "Expr::current_timestamp()")] #[sea_orm(default_expr = "Expr::current_timestamp()")]
pub created_at: DateTimeUtc, pub created_at: DateTime,
#[sea_orm(default_expr = "Expr::current_timestamp()")] #[sea_orm(default_expr = "Expr::current_timestamp()")]
pub updated_at: DateTimeUtc, pub updated_at: DateTime,
#[sea_orm(primary_key)] #[sea_orm(primary_key)]
pub id: i32, pub id: i32,
pub category: DownloaderCategory, pub category: DownloaderCategory,

View File

@ -39,9 +39,9 @@ pub enum DownloadMime {
#[sea_orm(table_name = "downloads")] #[sea_orm(table_name = "downloads")]
pub struct Model { pub struct Model {
#[sea_orm(default_expr = "Expr::current_timestamp()")] #[sea_orm(default_expr = "Expr::current_timestamp()")]
pub created_at: DateTimeUtc, pub created_at: DateTime,
#[sea_orm(default_expr = "Expr::current_timestamp()")] #[sea_orm(default_expr = "Expr::current_timestamp()")]
pub updated_at: DateTimeUtc, pub updated_at: DateTime,
#[sea_orm(primary_key)] #[sea_orm(primary_key)]
pub id: i32, pub id: i32,
pub raw_name: String, pub raw_name: String,

View File

@ -9,7 +9,7 @@ use crate::{
app::AppContextTrait, app::AppContextTrait,
errors::RecorderResult, errors::RecorderResult,
extract::{ extract::{
mikan::{MikanEpisodeMeta, build_mikan_episode_homepage_url}, mikan::{MikanEpisodeMeta, build_mikan_episode_homepage},
rawname::parse_episode_meta_from_raw_name, rawname::parse_episode_meta_from_raw_name,
}, },
}; };
@ -28,9 +28,9 @@ pub struct EpisodeExtra {
#[sea_orm(table_name = "episodes")] #[sea_orm(table_name = "episodes")]
pub struct Model { pub struct Model {
#[sea_orm(default_expr = "Expr::current_timestamp()")] #[sea_orm(default_expr = "Expr::current_timestamp()")]
pub created_at: DateTimeUtc, pub created_at: DateTime,
#[sea_orm(default_expr = "Expr::current_timestamp()")] #[sea_orm(default_expr = "Expr::current_timestamp()")]
pub updated_at: DateTimeUtc, pub updated_at: DateTime,
#[sea_orm(primary_key)] #[sea_orm(primary_key)]
pub id: i32, pub id: i32,
#[sea_orm(indexed)] #[sea_orm(indexed)]
@ -200,10 +200,8 @@ impl ActiveModel {
}) })
.ok() .ok()
.unwrap_or_default(); .unwrap_or_default();
let homepage = build_mikan_episode_homepage_url( let homepage =
ctx.mikan().base_url().clone(), build_mikan_episode_homepage(ctx.mikan().base_url().clone(), &item.mikan_episode_id);
&item.mikan_episode_id,
);
Ok(Self { Ok(Self {
mikan_episode_id: ActiveValue::Set(Some(item.mikan_episode_id)), mikan_episode_id: ActiveValue::Set(Some(item.mikan_episode_id)),

View File

@ -1,6 +1,5 @@
pub mod auth; pub mod auth;
pub mod bangumi; pub mod bangumi;
pub mod credential_3rd;
pub mod downloaders; pub mod downloaders;
pub mod downloads; pub mod downloads;
pub mod episodes; pub mod episodes;

View File

@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
use crate::{ use crate::{
app::AppContextTrait, app::AppContextTrait,
errors::app_error::{RecorderError, RecorderResult}, errors::app_error::{RecorderResult, RecorderError},
}; };
pub const SEED_SUBSCRIBER: &str = "konobangu"; pub const SEED_SUBSCRIBER: &str = "konobangu";
@ -21,9 +21,9 @@ pub struct SubscriberBangumiConfig {
#[sea_orm(table_name = "subscribers")] #[sea_orm(table_name = "subscribers")]
pub struct Model { pub struct Model {
#[sea_orm(default_expr = "Expr::current_timestamp()")] #[sea_orm(default_expr = "Expr::current_timestamp()")]
pub created_at: DateTimeUtc, pub created_at: DateTime,
#[sea_orm(default_expr = "Expr::current_timestamp()")] #[sea_orm(default_expr = "Expr::current_timestamp()")]
pub updated_at: DateTimeUtc, pub updated_at: DateTime,
#[sea_orm(primary_key)] #[sea_orm(primary_key)]
pub id: i32, pub id: i32,
pub display_name: String, pub display_name: String,

View File

@ -11,11 +11,13 @@ use crate::{
errors::RecorderResult, errors::RecorderResult,
extract::{ extract::{
mikan::{ mikan::{
MikanBangumiPosterMeta, build_mikan_bangumi_homepage_url, build_mikan_bangumi_rss_url, build_mikan_bangumi_homepage, build_mikan_bangumi_rss_link,
extract_mikan_bangumi_meta_from_bangumi_homepage,
extract_mikan_episode_meta_from_episode_homepage,
extract_mikan_rss_channel_from_rss_link, extract_mikan_rss_channel_from_rss_link,
scrape_mikan_bangumi_meta_from_bangumi_homepage_url, web_extract::{
scrape_mikan_episode_meta_from_episode_homepage_url, MikanBangumiPosterMeta, extract_mikan_bangumi_poster_meta_from_src_with_cache,
scrape_mikan_poster_meta_from_image_url, },
}, },
rawname::extract_season_from_title_body, rawname::extract_season_from_title_body,
}, },
@ -42,9 +44,9 @@ pub enum SubscriptionCategory {
#[sea_orm(table_name = "subscriptions")] #[sea_orm(table_name = "subscriptions")]
pub struct Model { pub struct Model {
#[sea_orm(default_expr = "Expr::current_timestamp()")] #[sea_orm(default_expr = "Expr::current_timestamp()")]
pub created_at: DateTimeUtc, pub created_at: DateTime,
#[sea_orm(default_expr = "Expr::current_timestamp()")] #[sea_orm(default_expr = "Expr::current_timestamp()")]
pub updated_at: DateTimeUtc, pub updated_at: DateTime,
#[sea_orm(primary_key)] #[sea_orm(primary_key)]
pub id: i32, pub id: i32,
pub display_name: String, pub display_name: String,
@ -52,7 +54,6 @@ pub struct Model {
pub category: SubscriptionCategory, pub category: SubscriptionCategory,
pub source_url: String, pub source_url: String,
pub enabled: bool, pub enabled: bool,
pub credential_id: Option<i32>,
} }
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@ -73,14 +74,6 @@ pub enum Relation {
SubscriptionEpisode, SubscriptionEpisode,
#[sea_orm(has_many = "super::subscription_bangumi::Entity")] #[sea_orm(has_many = "super::subscription_bangumi::Entity")]
SubscriptionBangumi, SubscriptionBangumi,
#[sea_orm(
belongs_to = "super::credential_3rd::Entity",
from = "Column::CredentialId",
to = "super::credential_3rd::Column::Id",
on_update = "Cascade",
on_delete = "SetNull"
)]
Credential3rd,
} }
impl Related<super::subscribers::Entity> for Entity { impl Related<super::subscribers::Entity> for Entity {
@ -129,12 +122,6 @@ impl Related<super::episodes::Entity> for Entity {
} }
} }
impl Related<super::credential_3rd::Entity> for Entity {
fn to() -> RelationDef {
Relation::Credential3rd.def()
}
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelatedEntity)] #[derive(Copy, Clone, Debug, EnumIter, DeriveRelatedEntity)]
pub enum RelatedEntity { pub enum RelatedEntity {
#[sea_orm(entity = "super::subscribers::Entity")] #[sea_orm(entity = "super::subscribers::Entity")]
@ -147,8 +134,6 @@ pub enum RelatedEntity {
SubscriptionEpisode, SubscriptionEpisode,
#[sea_orm(entity = "super::subscription_bangumi::Entity")] #[sea_orm(entity = "super::subscription_bangumi::Entity")]
SubscriptionBangumi, SubscriptionBangumi,
#[sea_orm(entity = "super::credential_3rd::Entity")]
Credential3rd,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@ -270,7 +255,7 @@ impl Model {
let mut new_metas = vec![]; let mut new_metas = vec![];
for new_rss_item in new_rss_items.iter() { for new_rss_item in new_rss_items.iter() {
new_metas.push( new_metas.push(
scrape_mikan_episode_meta_from_episode_homepage_url( extract_mikan_episode_meta_from_episode_homepage(
mikan_client, mikan_client,
new_rss_item.homepage.clone(), new_rss_item.homepage.clone(),
) )
@ -285,12 +270,12 @@ impl Model {
for ((mikan_bangumi_id, mikan_fansub_id), new_ep_metas) in new_mikan_bangumi_groups for ((mikan_bangumi_id, mikan_fansub_id), new_ep_metas) in new_mikan_bangumi_groups
{ {
let mikan_base_url = ctx.mikan().base_url(); let mikan_base_url = ctx.mikan().base_url();
let bgm_homepage = build_mikan_bangumi_homepage_url( let bgm_homepage = build_mikan_bangumi_homepage(
mikan_base_url.clone(), mikan_base_url.clone(),
&mikan_bangumi_id, &mikan_bangumi_id,
Some(&mikan_fansub_id), Some(&mikan_fansub_id),
); );
let bgm_rss_link = build_mikan_bangumi_rss_url( let bgm_rss_link = build_mikan_bangumi_rss_link(
mikan_base_url.clone(), mikan_base_url.clone(),
&mikan_bangumi_id, &mikan_bangumi_id,
Some(&mikan_fansub_id), Some(&mikan_fansub_id),
@ -303,7 +288,7 @@ impl Model {
mikan_bangumi_id.to_string(), mikan_bangumi_id.to_string(),
mikan_fansub_id.to_string(), mikan_fansub_id.to_string(),
async |am| -> RecorderResult<()> { async |am| -> RecorderResult<()> {
let bgm_meta = scrape_mikan_bangumi_meta_from_bangumi_homepage_url( let bgm_meta = extract_mikan_bangumi_meta_from_bangumi_homepage(
mikan_client, mikan_client,
bgm_homepage.clone(), bgm_homepage.clone(),
) )
@ -317,20 +302,20 @@ impl Model {
am.season_raw = ActiveValue::Set(bgm_season_raw); am.season_raw = ActiveValue::Set(bgm_season_raw);
am.rss_link = ActiveValue::Set(Some(bgm_rss_link.to_string())); am.rss_link = ActiveValue::Set(Some(bgm_rss_link.to_string()));
am.homepage = ActiveValue::Set(Some(bgm_homepage.to_string())); am.homepage = ActiveValue::Set(Some(bgm_homepage.to_string()));
am.fansub = ActiveValue::Set(Some(bgm_meta.fansub)); am.fansub = ActiveValue::Set(bgm_meta.fansub);
if let Some(origin_poster_src) = bgm_meta.origin_poster_src if let Some(origin_poster_src) = bgm_meta.origin_poster_src {
&& let MikanBangumiPosterMeta { if let MikanBangumiPosterMeta {
poster_src: Some(poster_src), poster_src: Some(poster_src),
.. ..
} = scrape_mikan_poster_meta_from_image_url( } = extract_mikan_bangumi_poster_meta_from_src_with_cache(
mikan_client, ctx,
ctx.storage(),
origin_poster_src, origin_poster_src,
self.subscriber_id, self.subscriber_id,
) )
.await? .await?
{ {
am.poster_link = ActiveValue::Set(Some(poster_src)) am.poster_link = ActiveValue::Set(Some(poster_src))
}
} }
Ok(()) Ok(())
}, },

View File

@ -1,13 +1,14 @@
use std::fmt; use std::fmt;
use bytes::Bytes; use bytes::Bytes;
use opendal::{Buffer, Operator, layers::LoggingLayer}; use opendal::{Buffer, Operator, layers::LoggingLayer, services::Fs};
use quirks_path::{Path, PathBuf}; use quirks_path::{Path, PathBuf};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use url::Url; use url::Url;
use uuid::Uuid;
use super::StorageConfig; use super::StorageConfig;
use crate::errors::app_error::RecorderResult; use crate::errors::app_error::{RecorderError, RecorderResult};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
@ -43,88 +44,6 @@ impl fmt::Display for StorageStoredUrl {
} }
} }
#[async_trait::async_trait]
pub trait StorageServiceTrait: Sync {
fn get_operator(&self) -> RecorderResult<Operator>;
fn get_fullname(
&self,
content_category: StorageContentCategory,
subscriber_id: i32,
bucket: Option<&str>,
filename: &str,
) -> PathBuf {
[
&subscriber_id.to_string(),
content_category.as_ref(),
bucket.unwrap_or_default(),
filename,
]
.into_iter()
.map(Path::new)
.collect::<PathBuf>()
}
async fn store_object(
&self,
content_category: StorageContentCategory,
subscriber_id: i32,
bucket: Option<&str>,
filename: &str,
data: Bytes,
) -> RecorderResult<StorageStoredUrl> {
let fullname = self.get_fullname(content_category, subscriber_id, bucket, filename);
let operator = self.get_operator()?;
if let Some(dirname) = fullname.parent() {
let dirname = dirname.join("/");
operator.create_dir(dirname.as_str()).await?;
}
operator.write(fullname.as_str(), data).await?;
Ok(StorageStoredUrl::RelativePath {
path: fullname.to_string(),
})
}
async fn exists_object(
&self,
content_category: StorageContentCategory,
subscriber_id: i32,
bucket: Option<&str>,
filename: &str,
) -> RecorderResult<Option<StorageStoredUrl>> {
let fullname = self.get_fullname(content_category, subscriber_id, bucket, filename);
let operator = self.get_operator()?;
if operator.exists(fullname.as_str()).await? {
Ok(Some(StorageStoredUrl::RelativePath {
path: fullname.to_string(),
}))
} else {
Ok(None)
}
}
async fn load_object(
&self,
content_category: StorageContentCategory,
subscriber_id: i32,
bucket: Option<&str>,
filename: &str,
) -> RecorderResult<Buffer> {
let fullname = self.get_fullname(content_category, subscriber_id, bucket, filename);
let operator = self.get_operator()?;
let data = operator.read(fullname.as_str()).await?;
Ok(data)
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct StorageService { pub struct StorageService {
pub data_dir: String, pub data_dir: String,
@ -136,15 +55,114 @@ impl StorageService {
data_dir: config.data_dir.to_string(), data_dir: config.data_dir.to_string(),
}) })
} }
}
#[async_trait::async_trait] pub fn get_fs(&self) -> Fs {
impl StorageServiceTrait for StorageService { Fs::default().root(&self.data_dir)
fn get_operator(&self) -> RecorderResult<Operator> { }
let fs_op = Operator::new(opendal::services::Fs::default().root(&self.data_dir))?
.layer(LoggingLayer::default())
.finish();
Ok(fs_op) pub fn create_filename(extname: &str) -> String {
format!("{}{}", Uuid::new_v4(), extname)
}
pub async fn store_object(
&self,
content_category: StorageContentCategory,
subscriber_id: i32,
bucket: Option<&str>,
filename: &str,
data: Bytes,
) -> Result<StorageStoredUrl, RecorderError> {
match content_category {
StorageContentCategory::Image => {
let fullname = [
&subscriber_id.to_string(),
content_category.as_ref(),
bucket.unwrap_or_default(),
filename,
]
.into_iter()
.map(Path::new)
.collect::<PathBuf>();
let fs_op = Operator::new(self.get_fs())?
.layer(LoggingLayer::default())
.finish();
if let Some(dirname) = fullname.parent() {
let dirname = dirname.join("/");
fs_op.create_dir(dirname.as_str()).await?;
}
fs_op.write(fullname.as_str(), data).await?;
Ok(StorageStoredUrl::RelativePath {
path: fullname.to_string(),
})
}
}
}
pub async fn exists_object(
&self,
content_category: StorageContentCategory,
subscriber_id: i32,
bucket: Option<&str>,
filename: &str,
) -> Result<Option<StorageStoredUrl>, RecorderError> {
match content_category {
StorageContentCategory::Image => {
let fullname = [
&subscriber_id.to_string(),
content_category.as_ref(),
bucket.unwrap_or_default(),
filename,
]
.into_iter()
.map(Path::new)
.collect::<PathBuf>();
let fs_op = Operator::new(self.get_fs())?
.layer(LoggingLayer::default())
.finish();
if fs_op.exists(fullname.as_str()).await? {
Ok(Some(StorageStoredUrl::RelativePath {
path: fullname.to_string(),
}))
} else {
Ok(None)
}
}
}
}
pub async fn load_object(
&self,
content_category: StorageContentCategory,
subscriber_pid: &str,
bucket: Option<&str>,
filename: &str,
) -> RecorderResult<Buffer> {
match content_category {
StorageContentCategory::Image => {
let fullname = [
subscriber_pid,
content_category.as_ref(),
bucket.unwrap_or_default(),
filename,
]
.into_iter()
.map(Path::new)
.collect::<PathBuf>();
let fs_op = Operator::new(self.get_fs())?
.layer(LoggingLayer::default())
.finish();
let data = fs_op.read(fullname.as_str()).await?;
Ok(data)
}
}
} }
} }

View File

@ -1,4 +1,4 @@
mod client; pub mod client;
mod config; pub mod config;
pub use client::{StorageContentCategory, StorageService, StorageServiceTrait, StorageStoredUrl}; pub use client::{StorageContentCategory, StorageService};
pub use config::StorageConfig; pub use config::StorageConfig;

View File

@ -1,4 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskConfig {}

View File

@ -0,0 +1,279 @@
use std::{borrow::Cow, sync::Arc};
use async_stream::stream;
use futures::{Stream, StreamExt, pin_mut};
use serde::{Serialize, de::DeserializeOwned};
use tokio::sync::{RwLock, mpsc};
use crate::{
app::AppContextTrait,
errors::app_error::{RecorderError, RecorderResult},
models,
};
pub struct TaskMeta {
pub subscriber_id: i32,
pub task_id: i32,
pub task_kind: Cow<'static, str>,
}
pub struct ReplayChannel<T: Send + Sync + Clone + 'static> {
sender: mpsc::UnboundedSender<T>,
channels: Arc<RwLock<Vec<mpsc::UnboundedSender<T>>>>,
buffer: Arc<RwLock<Vec<T>>>,
}
impl<T: Send + Sync + Clone + 'static> ReplayChannel<T> {
pub fn new(history: Vec<T>) -> Self {
let (tx, mut rx) = mpsc::unbounded_channel::<T>();
let channels = Arc::new(RwLock::new(Vec::<mpsc::UnboundedSender<T>>::new()));
let buffer = Arc::new(RwLock::new(history));
{
let channels = channels.clone();
let buffer = buffer.clone();
tokio::spawn(async move {
loop {
match rx.recv().await {
Some(value) => {
let mut w = buffer.write().await;
let senders = channels.read().await;
for s in senders.iter() {
if !s.is_closed() {
if let Err(err) = s.send(value.clone()) {
tracing::error!(err = %err, "replay-channel broadcast to other subscribers error");
}
}
}
w.push(value);
}
None => {
drop(rx);
let mut cs = channels.write().await;
cs.clear();
break;
}
}
}
});
}
Self {
sender: tx,
channels,
buffer,
}
}
pub fn sender(&self) -> &mpsc::UnboundedSender<T> {
&self.sender
}
pub async fn receiver(&self) -> mpsc::UnboundedReceiver<T> {
let (tx, rx) = mpsc::unbounded_channel();
let items = self.buffer.read().await;
for item in items.iter() {
if let Err(err) = tx.send(item.clone()) {
tracing::error!(err = %err, "replay-channel send replay value to other subscribers error");
}
}
if !self.sender.is_closed() {
let mut sw = self.channels.write().await;
sw.push(tx);
}
rx
}
pub async fn close(&self) {
let mut senders = self.channels.write().await;
senders.clear();
}
}
pub trait StreamTaskCoreTrait: Sized {
type Request: Serialize + DeserializeOwned;
type Item: Serialize + DeserializeOwned;
fn task_id(&self) -> i32;
fn task_kind(&self) -> &str;
fn new(meta: TaskMeta, request: Self::Request) -> Self;
fn request(&self) -> &Self::Request;
}
pub trait StreamTaskReplayLayoutTrait: StreamTaskCoreTrait {
fn history(&self) -> &[Arc<RecorderResult<Self::Item>>];
fn resume_from_model(
task: models::tasks::Model,
stream_items: Vec<models::task_stream_item::Model>,
) -> RecorderResult<Self>;
fn running_receiver(
&self,
) -> impl Future<Output = Option<mpsc::UnboundedReceiver<Arc<RecorderResult<Self::Item>>>>>;
#[allow(clippy::type_complexity)]
fn init_receiver(
&self,
) -> impl Future<
Output = (
mpsc::UnboundedSender<Arc<RecorderResult<Self::Item>>>,
mpsc::UnboundedReceiver<Arc<RecorderResult<Self::Item>>>,
),
>;
fn serialize_request(request: Self::Request) -> RecorderResult<serde_json::Value> {
serde_json::to_value(request).map_err(RecorderError::from)
}
fn serialize_item(item: RecorderResult<Self::Item>) -> RecorderResult<serde_json::Value> {
serde_json::to_value(item).map_err(RecorderError::from)
}
fn deserialize_request(request: serde_json::Value) -> RecorderResult<Self::Request> {
serde_json::from_value(request).map_err(RecorderError::from)
}
fn deserialize_item(item: serde_json::Value) -> RecorderResult<RecorderResult<Self::Item>> {
serde_json::from_value(item).map_err(RecorderError::from)
}
}
pub trait StreamTaskRunnerTrait: StreamTaskCoreTrait {
fn run(
context: Arc<dyn AppContextTrait>,
request: &Self::Request,
history: &[Arc<RecorderResult<Self::Item>>],
) -> impl Stream<Item = RecorderResult<Self::Item>>;
}
pub trait StreamTaskReplayRunnerTrait: StreamTaskRunnerTrait + StreamTaskReplayLayoutTrait {
fn run_shared(
&self,
context: Arc<dyn AppContextTrait>,
) -> impl Stream<Item = Arc<RecorderResult<Self::Item>>> {
stream! {
if let Some(mut receiver) = self.running_receiver().await {
while let Some(item) = receiver.recv().await {
yield item
}
} else {
let (tx, _) = self.init_receiver().await;
let stream = Self::run(context, self.request(), self.history());
pin_mut!(stream);
while let Some(item) = stream.next().await {
let item = Arc::new(item);
if let Err(err) = tx.send(item.clone()) {
tracing::error!(task_id = self.task_id(), task_kind = self.task_kind(), err = %err, "run shared send error");
}
yield item
}
};
}
}
}
pub struct StandardStreamTaskReplayLayout<Request, Item>
where
Request: Serialize + DeserializeOwned,
Item: Serialize + DeserializeOwned + Sync + Send + 'static,
{
pub meta: TaskMeta,
pub request: Request,
pub history: Vec<Arc<RecorderResult<Item>>>,
#[allow(clippy::type_complexity)]
pub channel: Arc<RwLock<Option<ReplayChannel<Arc<RecorderResult<Item>>>>>>,
}
impl<Request, Item> StreamTaskCoreTrait for StandardStreamTaskReplayLayout<Request, Item>
where
Request: Serialize + DeserializeOwned,
Item: Serialize + DeserializeOwned + Sync + Send + 'static,
{
type Request = Request;
type Item = Item;
fn task_id(&self) -> i32 {
self.meta.task_id
}
fn request(&self) -> &Self::Request {
&self.request
}
fn task_kind(&self) -> &str {
&self.meta.task_kind
}
fn new(meta: TaskMeta, request: Self::Request) -> Self {
Self {
meta,
request,
history: vec![],
channel: Arc::new(RwLock::new(None)),
}
}
}
impl<Request, Item> StreamTaskReplayLayoutTrait for StandardStreamTaskReplayLayout<Request, Item>
where
Request: Serialize + DeserializeOwned,
Item: Serialize + DeserializeOwned + Sync + Send + 'static,
{
fn history(&self) -> &[Arc<RecorderResult<Self::Item>>] {
&self.history
}
fn resume_from_model(
task: models::tasks::Model,
stream_items: Vec<models::task_stream_item::Model>,
) -> RecorderResult<Self> {
Ok(Self {
meta: TaskMeta {
task_id: task.id,
subscriber_id: task.subscriber_id,
task_kind: Cow::Owned(task.task_type),
},
request: Self::deserialize_request(task.request_data)?,
history: stream_items
.into_iter()
.map(|m| Self::deserialize_item(m.item).map(Arc::new))
.collect::<RecorderResult<Vec<_>>>()?,
channel: Arc::new(RwLock::new(None)),
})
}
async fn running_receiver(
&self,
) -> Option<mpsc::UnboundedReceiver<Arc<RecorderResult<Self::Item>>>> {
if let Some(channel) = self.channel.read().await.as_ref() {
Some(channel.receiver().await)
} else {
None
}
}
async fn init_receiver(
&self,
) -> (
mpsc::UnboundedSender<Arc<RecorderResult<Self::Item>>>,
mpsc::UnboundedReceiver<Arc<RecorderResult<Self::Item>>>,
) {
let channel = ReplayChannel::new(self.history.clone());
let rx = channel.receiver().await;
let sender = channel.sender().clone();
{
{
let mut w = self.channel.write().await;
*w = Some(channel);
}
}
(sender, rx)
}
}

View File

@ -0,0 +1,37 @@
use std::sync::Arc;
use futures::Stream;
use serde::{Deserialize, Serialize};
use url::Url;
use crate::{
app::AppContextTrait,
errors::RecorderResult,
extract::mikan::{MikanAuthSecrecy, MikanBangumiMeta, web_extract},
tasks::core::{StandardStreamTaskReplayLayout, StreamTaskRunnerTrait},
};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ExtractMikanBangumisMetaFromMyBangumiRequest {
pub my_bangumi_page_url: Url,
pub auth_secrecy: Option<MikanAuthSecrecy>,
}
pub type ExtractMikanBangumisMetaFromMyBangumiTask =
StandardStreamTaskReplayLayout<ExtractMikanBangumisMetaFromMyBangumiRequest, MikanBangumiMeta>;
impl StreamTaskRunnerTrait for ExtractMikanBangumisMetaFromMyBangumiTask {
fn run(
context: Arc<dyn AppContextTrait>,
request: &Self::Request,
history: &[Arc<RecorderResult<Self::Item>>],
) -> impl Stream<Item = RecorderResult<Self::Item>> {
let context = context.clone();
web_extract::extract_mikan_bangumis_meta_from_my_bangumi_page(
context,
request.my_bangumi_page_url.clone(),
request.auth_secrecy.clone(),
history,
)
}
}

View File

@ -1,87 +0,0 @@
use std::{ops::Deref, sync::Arc};
use apalis::prelude::*;
use apalis_sql::postgres::PostgresStorage;
use serde::{Deserialize, Serialize};
use crate::{
app::AppContextTrait,
errors::RecorderResult,
extract::mikan::{
MikanBangumiMeta, MikanSeasonStr, build_mikan_season_flow_url,
scrape_mikan_bangumi_meta_list_from_season_flow_url,
},
};
const TASK_NAME: &str = "mikan_extract_season_subscription";
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ExtractMikanSeasonSubscriptionTask {
pub task_id: i32,
pub year: i32,
pub season_str: MikanSeasonStr,
pub credential_id: i32,
pub subscription_id: i32,
pub subscriber_id: i32,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ExtractMikanSeasonSubscriptionTaskResult {
pub task_id: i32,
pub year: i32,
pub season_str: MikanSeasonStr,
pub credential_id: i32,
pub subscription_id: i32,
pub subscriber_id: i32,
pub bangumi_meta_list: Vec<MikanBangumiMeta>,
}
pub async fn extract_mikan_season_subscription(
job: ExtractMikanSeasonSubscriptionTask,
data: Data<Arc<dyn AppContextTrait>>,
) -> RecorderResult<GoTo<ExtractMikanSeasonSubscriptionTaskResult>> {
let ctx = data.deref();
let mikan_client = ctx.mikan();
let mikan_base_url = mikan_client.base_url();
let mikan_season_flow_url =
build_mikan_season_flow_url(mikan_base_url.clone(), job.year, job.season_str);
let bangumi_meta_list = scrape_mikan_bangumi_meta_list_from_season_flow_url(
mikan_client,
ctx.clone(),
mikan_season_flow_url,
job.credential_id,
)
.await?;
Ok(GoTo::Done(ExtractMikanSeasonSubscriptionTaskResult {
bangumi_meta_list,
credential_id: job.credential_id,
season_str: job.season_str,
subscriber_id: job.subscriber_id,
subscription_id: job.subscription_id,
task_id: job.task_id,
year: job.year,
}))
}
pub fn register_extract_mikan_season_subscription_task(
monitor: Monitor,
ctx: Arc<dyn AppContextTrait>,
) -> RecorderResult<(Monitor, PostgresStorage<StepRequest<serde_json::Value>>)> {
let pool = ctx.db().get_postgres_connection_pool().clone();
let storage = PostgresStorage::new(pool);
let steps = StepBuilder::new().step_fn(extract_mikan_season_subscription);
let worker = WorkerBuilder::new(TASK_NAME)
.catch_panic()
.enable_tracing()
.data(ctx)
.backend(storage.clone())
.build_stepped(steps);
Ok((monitor.register(worker), storage))
}

View File

@ -1,5 +1 @@
mod extract_season_subscription; pub mod extract_mikan_bangumis_meta_from_my_bangumi;
pub use extract_season_subscription::{
ExtractMikanSeasonSubscriptionTask, register_extract_mikan_season_subscription_task,
};

View File

@ -1,6 +1,4 @@
pub mod config; pub mod core;
pub mod mikan; pub mod mikan;
pub mod service; pub mod service;
pub mod registry;
pub use config::TaskConfig;
pub use service::TaskService;

View File

@ -0,0 +1 @@

View File

@ -1,41 +1,4 @@
use std::{fmt::Debug, sync::Arc}; #[derive(Debug)]
pub struct TaskService {}
use apalis::prelude::*; impl TaskService {}
use apalis_sql::postgres::PostgresStorage;
use tokio::sync::Mutex;
use super::{TaskConfig, mikan::register_extract_mikan_season_subscription_task};
use crate::{app::AppContextTrait, errors::RecorderResult};
pub struct TaskService {
config: TaskConfig,
#[allow(dead_code)]
monitor: Arc<Mutex<Monitor>>,
pub extract_mikan_season_subscription_task_storage:
PostgresStorage<StepRequest<serde_json::Value>>,
}
impl TaskService {
pub async fn from_config_and_ctx(
config: TaskConfig,
ctx: Arc<dyn AppContextTrait>,
) -> RecorderResult<Self> {
let monitor = Monitor::new();
let (monitor, extract_mikan_season_subscription_task_storage) =
register_extract_mikan_season_subscription_task(monitor, ctx.clone())?;
Ok(Self {
config,
monitor: Arc::new(Mutex::new(monitor)),
extract_mikan_season_subscription_task_storage,
})
}
}
impl Debug for TaskService {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TaskService")
.field("config", &self.config)
.finish()
}
}

View File

@ -1,5 +1,3 @@
use std::fmt::Debug;
use typed_builder::TypedBuilder; use typed_builder::TypedBuilder;
use crate::app::AppContextTrait; use crate::app::AppContextTrait;
@ -15,20 +13,12 @@ pub struct UnitTestAppContext {
auth: Option<crate::auth::AuthService>, auth: Option<crate::auth::AuthService>,
graphql: Option<crate::graphql::GraphQLService>, graphql: Option<crate::graphql::GraphQLService>,
storage: Option<crate::storage::StorageService>, storage: Option<crate::storage::StorageService>,
crypto: Option<crate::crypto::CryptoService>,
tasks: Option<crate::tasks::TaskService>,
#[builder(default = Some(String::from(env!("CARGO_MANIFEST_DIR"))))] #[builder(default = Some(String::from(env!("CARGO_MANIFEST_DIR"))))]
working_dir: Option<String>, working_dir: Option<String>,
#[builder(default = crate::app::Environment::Testing, setter(!strip_option))] #[builder(default = crate::app::Environment::Testing, setter(!strip_option))]
environment: crate::app::Environment, environment: crate::app::Environment,
} }
impl Debug for UnitTestAppContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "UnitTestAppContext")
}
}
impl AppContextTrait for UnitTestAppContext { impl AppContextTrait for UnitTestAppContext {
fn logger(&self) -> &crate::logger::LoggerService { fn logger(&self) -> &crate::logger::LoggerService {
self.logger.as_ref().expect("should set logger") self.logger.as_ref().expect("should set logger")
@ -58,7 +48,7 @@ impl AppContextTrait for UnitTestAppContext {
self.graphql.as_ref().expect("should set graphql") self.graphql.as_ref().expect("should set graphql")
} }
fn storage(&self) -> &dyn crate::storage::StorageServiceTrait { fn storage(&self) -> &crate::storage::StorageService {
self.storage.as_ref().expect("should set storage") self.storage.as_ref().expect("should set storage")
} }
@ -69,12 +59,4 @@ impl AppContextTrait for UnitTestAppContext {
fn working_dir(&self) -> &String { fn working_dir(&self) -> &String {
self.working_dir.as_ref().expect("should set working_dir") self.working_dir.as_ref().expect("should set working_dir")
} }
fn crypto(&self) -> &crate::crypto::CryptoService {
self.crypto.as_ref().expect("should set crypto")
}
fn task(&self) -> &crate::tasks::TaskService {
self.tasks.as_ref().expect("should set tasks")
}
} }

View File

@ -1,9 +0,0 @@
use crate::{
crypto::{CryptoConfig, CryptoService},
errors::RecorderResult,
};
pub async fn build_testing_crypto_service() -> RecorderResult<CryptoService> {
let crypto = CryptoService::from_config(CryptoConfig {}).await?;
Ok(crypto)
}

View File

@ -1,60 +0,0 @@
use crate::{
database::{DatabaseConfig, DatabaseService},
errors::RecorderResult,
};
#[cfg(feature = "testcontainers")]
pub async fn build_testing_database_service() -> RecorderResult<DatabaseService> {
use testcontainers::{ImageExt, runners::AsyncRunner};
use testcontainers_ext::{ImageDefaultLogConsumerExt, ImagePruneExistedLabelExt};
use testcontainers_modules::postgres::Postgres;
let container = Postgres::default()
.with_db_name("konobangu")
.with_user("konobangu")
.with_password("konobangu")
.with_tag("17-alpine")
.with_default_log_consumer()
.with_prune_existed_label(env!("CARGO_PKG_NAME"), "postgres", true, true)
.await?;
let container = container.start().await?;
let host_ip = container.get_host().await?;
let host_port = container.get_host_port_ipv4(5432).await?;
let connection_string =
format!("postgres://konobangu:konobangu@{host_ip}:{host_port}/konobangu");
let mut db_service = DatabaseService::from_config(DatabaseConfig {
uri: connection_string,
enable_logging: true,
min_connections: 1,
max_connections: 1,
connect_timeout: 5000,
idle_timeout: 10000,
acquire_timeout: None,
auto_migrate: true,
})
.await?;
db_service.container = Some(container);
Ok(db_service)
}
#[cfg(not(feature = "testcontainers"))]
pub async fn build_testing_database_service() -> RecorderResult<DatabaseService> {
let db_service = DatabaseService::from_config(DatabaseConfig {
uri: String::from("postgres://konobangu:konobangu@127.0.0.1:5432/konobangu"),
enable_logging: true,
min_connections: 1,
max_connections: 1,
connect_timeout: 5000,
idle_timeout: 10000,
acquire_timeout: None,
auto_migrate: true,
})
.await?;
Ok(db_service)
}

View File

@ -1,22 +1,10 @@
use std::collections::HashMap; use fetch::{FetchError, HttpClientConfig, IntoUrl};
use chrono::{Duration, Utc};
use fetch::{FetchError, HttpClientConfig, IntoUrl, get_random_ua};
use url::Url;
use crate::{ use crate::{
errors::RecorderResult, errors::RecorderResult,
extract::mikan::{ extract::mikan::{MikanClient, MikanConfig},
MIKAN_ACCOUNT_MANAGE_PAGE_PATH, MIKAN_LOGIN_PAGE_PATH, MikanClient, MikanConfig,
MikanCredentialForm,
},
}; };
const TESTING_MIKAN_USERNAME: &str = "test_username";
const TESTING_MIKAN_PASSWORD: &str = "test_password";
const TESTING_MIKAN_ANTIFORGERY: &str = "test_antiforgery";
const TESTING_MIKAN_IDENTITY: &str = "test_identity";
pub async fn build_testing_mikan_client( pub async fn build_testing_mikan_client(
base_mikan_url: impl IntoUrl, base_mikan_url: impl IntoUrl,
) -> RecorderResult<MikanClient> { ) -> RecorderResult<MikanClient> {
@ -29,145 +17,3 @@ pub async fn build_testing_mikan_client(
.await?; .await?;
Ok(mikan_client) Ok(mikan_client)
} }
pub fn build_testing_mikan_credential_form() -> MikanCredentialForm {
MikanCredentialForm {
username: String::from(TESTING_MIKAN_USERNAME),
password: String::from(TESTING_MIKAN_PASSWORD),
user_agent: get_random_ua().to_string(),
}
}
pub struct MikanMockServerLoginMock {
pub login_get_mock: mockito::Mock,
pub login_post_success_mock: mockito::Mock,
pub login_post_failed_mock: mockito::Mock,
pub account_get_success_mock: mockito::Mock,
pub account_get_failed_mock: mockito::Mock,
}
pub struct MikanMockServer {
pub server: mockito::ServerGuard,
base_url: Url,
}
impl MikanMockServer {
pub async fn new() -> RecorderResult<Self> {
let server = mockito::Server::new_async().await;
let base_url = Url::parse(&server.url())?;
Ok(Self { server, base_url })
}
pub fn base_url(&self) -> &Url {
&self.base_url
}
pub fn get_has_auth_matcher() -> impl Fn(&mockito::Request) -> bool {
|req: &mockito::Request| -> bool {
let test_identity_cookie =
format!(".AspNetCore.Identity.Application={TESTING_MIKAN_IDENTITY}");
req.header("Cookie").iter().any(|cookie| {
cookie
.to_str()
.is_ok_and(|c| c.contains(&test_identity_cookie))
})
}
}
pub fn mock_get_login_page(&mut self) -> MikanMockServerLoginMock {
let login_get_mock = self
.server
.mock("GET", MIKAN_LOGIN_PAGE_PATH)
.match_query(mockito::Matcher::Any)
.with_status(201)
.with_header("Content-Type", "text/html; charset=utf-8")
.with_header(
"Set-Cookie",
&format!(
".AspNetCore.Antiforgery.test_app_id={TESTING_MIKAN_ANTIFORGERY}; HttpOnly; \
SameSite=Strict; Path=/"
),
)
.create();
let test_identity_expires = (Utc::now() + Duration::days(30)).to_rfc2822();
let match_post_login_body = |req: &mockito::Request| {
req.body()
.map(|b| url::form_urlencoded::parse(b))
.is_ok_and(|queires| {
let qs = queires.collect::<HashMap<_, _>>();
qs.get("UserName")
.is_some_and(|s| s == TESTING_MIKAN_USERNAME)
&& qs
.get("Password")
.is_some_and(|s| s == TESTING_MIKAN_PASSWORD)
&& qs
.get("__RequestVerificationToken")
.is_some_and(|s| s == TESTING_MIKAN_ANTIFORGERY)
})
};
let login_post_success_mock = {
let mikan_base_url = self.base_url().clone();
self.server
.mock("POST", MIKAN_LOGIN_PAGE_PATH)
.match_query(mockito::Matcher::Any)
.match_request(match_post_login_body)
.with_status(302)
.with_header(
"Set-Cookie",
&format!(
".AspNetCore.Identity.Application={TESTING_MIKAN_IDENTITY}; HttpOnly; \
SameSite=Lax; Path=/; Expires=${test_identity_expires}"
),
)
.with_header_from_request("Location", move |req| {
let request_url = mikan_base_url.join(req.path_and_query()).ok();
request_url
.and_then(|u| {
u.query_pairs()
.find(|(key, _)| key == "ReturnUrl")
.map(|(_, value)| value.to_string())
})
.unwrap_or(String::from("/"))
})
.create()
};
let login_post_failed_mock = self
.server
.mock("POST", MIKAN_LOGIN_PAGE_PATH)
.match_query(mockito::Matcher::Any)
.match_request(move |req| !match_post_login_body(req))
.with_status(200)
.with_body_from_file("tests/resources/mikan/LoginError.html")
.create();
let account_get_success_mock = self
.server
.mock("GET", MIKAN_ACCOUNT_MANAGE_PAGE_PATH)
.match_query(mockito::Matcher::Any)
.match_request(move |req| Self::get_has_auth_matcher()(req))
.with_status(200)
.create();
let account_get_failed_mock = self
.server
.mock("GET", MIKAN_ACCOUNT_MANAGE_PAGE_PATH)
.match_query(mockito::Matcher::Any)
.match_request(move |req| !Self::get_has_auth_matcher()(req))
.with_status(302)
.with_header("Location", MIKAN_LOGIN_PAGE_PATH)
.create();
MikanMockServerLoginMock {
login_get_mock,
login_post_success_mock,
login_post_failed_mock,
account_get_success_mock,
account_get_failed_mock,
}
}
}

View File

@ -1,6 +1,3 @@
pub mod app; pub mod app;
pub mod crypto;
pub mod database;
pub mod mikan; pub mod mikan;
pub mod storage;
pub mod tracing; pub mod tracing;

View File

@ -1,28 +0,0 @@
use opendal::{Operator, layers::LoggingLayer};
use crate::{errors::RecorderResult, storage::StorageServiceTrait};
pub struct TestingStorageService {
operator: Operator,
}
impl TestingStorageService {
pub fn new() -> RecorderResult<Self> {
let op = Operator::new(opendal::services::Memory::default())?
.layer(LoggingLayer::default())
.finish();
Ok(Self { operator: op })
}
}
#[async_trait::async_trait]
impl StorageServiceTrait for TestingStorageService {
fn get_operator(&self) -> RecorderResult<Operator> {
Ok(self.operator.clone())
}
}
pub async fn build_testing_storage_service() -> RecorderResult<TestingStorageService> {
TestingStorageService::new()
}

View File

@ -4,8 +4,7 @@ use tracing_subscriber::EnvFilter;
pub fn try_init_testing_tracing(level: Level) { pub fn try_init_testing_tracing(level: Level) {
let crate_name = env!("CARGO_PKG_NAME"); let crate_name = env!("CARGO_PKG_NAME");
let level = level.as_str().to_lowercase(); let level = level.as_str().to_lowercase();
let filter = EnvFilter::new(format!("{crate_name}[]={level}")) let filter = EnvFilter::new(format!("{}[]={}", crate_name, level))
.add_directive(format!("mockito[]={level}").parse().unwrap()) .add_directive(format!("mockito[]={}", level).parse().unwrap());
.add_directive(format!("sqlx[]={level}").parse().unwrap());
let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init(); let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init();
} }

View File

@ -5,8 +5,8 @@ use axum::{Extension, Router, extract::State, middleware::from_fn_with_state, ro
use super::core::Controller; use super::core::Controller;
use crate::{ use crate::{
app::{AppContextTrait, Environment}, app::AppContextTrait,
auth::{AuthUserInfo, auth_middleware}, auth::{AuthUserInfo, header_www_authenticate_middleware},
errors::RecorderResult, errors::RecorderResult,
}; };
@ -25,51 +25,9 @@ async fn graphql_handler(
graphql_service.schema.execute(req).await.into() graphql_service.schema.execute(req).await.into()
} }
// 检查是否是 introspection 查询
fn is_introspection_query(req: &async_graphql::Request) -> bool {
if let Some(operation) = &req.operation_name
&& operation.starts_with("__")
{
return true;
}
// 检查查询内容是否包含 introspection 字段
let query = req.query.as_str();
query.contains("__schema") || query.contains("__type") || query.contains("__typename")
}
async fn graphql_introspection_handler(
State(ctx): State<Arc<dyn AppContextTrait>>,
req: GraphQLRequest,
) -> GraphQLResponse {
let graphql_service = ctx.graphql();
let req = req.into_inner();
if !is_introspection_query(&req) {
return GraphQLResponse::from(async_graphql::Response::from_errors(vec![
async_graphql::ServerError::new(
"Only introspection queries are allowed on this endpoint",
None,
),
]));
}
graphql_service.schema.execute(req).await.into()
}
pub async fn create(ctx: Arc<dyn AppContextTrait>) -> RecorderResult<Controller> { pub async fn create(ctx: Arc<dyn AppContextTrait>) -> RecorderResult<Controller> {
let mut introspection_handler = post(graphql_introspection_handler);
if !matches!(ctx.environment(), Environment::Development) {
introspection_handler =
introspection_handler.layer(from_fn_with_state(ctx.clone(), auth_middleware));
}
let router = Router::<Arc<dyn AppContextTrait>>::new() let router = Router::<Arc<dyn AppContextTrait>>::new()
.route( .route("/", post(graphql_handler))
"/", .layer(from_fn_with_state(ctx, header_www_authenticate_middleware));
post(graphql_handler).layer(from_fn_with_state(ctx, auth_middleware)),
)
.route("/introspection", introspection_handler);
Ok(Controller::from_prefix(CONTROLLER_PREFIX, router)) Ok(Controller::from_prefix(CONTROLLER_PREFIX, router))
} }

View File

@ -97,14 +97,15 @@ where
let res_fut = async move { let res_fut = async move {
let response = future.await?; let response = future.await?;
let etag_from_response = response.headers().get(ETAG).cloned(); let etag_from_response = response.headers().get(ETAG).cloned();
if let Some(etag_in_request) = ifnm if let Some(etag_in_request) = ifnm {
&& let Some(etag_from_response) = etag_from_response if let Some(etag_from_response) = etag_from_response {
&& etag_in_request == etag_from_response if etag_in_request == etag_from_response {
{ return Ok(Response::builder()
return Ok(Response::builder() .status(StatusCode::NOT_MODIFIED)
.status(StatusCode::NOT_MODIFIED) .body(Body::empty())
.body(Body::empty()) .unwrap());
.unwrap()); }
}
} }
Ok(response) Ok(response)
}; };

View File

@ -1,28 +0,0 @@
<svg width="0" height="0">
<defs>
<clipPath id="clip-triangle-1">
<polygon points="0 15,80 15,100 0,120 15,1120 15,1120 370,0 370" />
</clipPath>
<clipPath id="clip-triangle-2">
<polygon points="0 15,310 15,330 0,350 15,1120 15,1120 370,0 370" />
</clipPath>
<clipPath id="clip-triangle-3">
<polygon points="0 15,540 15,560 0,580 15,1120 15,1120 370,0 370" />
</clipPath>
<clipPath id="clip-triangle-4">
<polygon points="0 15,770 15,790 0,810 15,1120 15,1120 370,0 370" />
</clipPath>
<clipPath id="clip-triangle-5">
<polygon points="0 15,995 15,1015 0,1035 15,1120 15,1120 370,0 370" />
</clipPath>
<linearGradient id="row-bg-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#e4dedf" />
<stop offset="100%" stop-color="#cbbcc4" />
</linearGradient>
</defs>
</svg>
<div class="sk-bangumi">
<div class="no-subscribe-bangumi"> >_< 您还没有订阅任何番组快去<a href="/">首页</a>添加订阅吧</div>
</div>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,840 +0,0 @@
<svg width="0" height="0">
<defs>
<clipPath id="clip-triangle-1">
<polygon points="0 15,80 15,100 0,120 15,1120 15,1120 370,0 370" />
</clipPath>
<clipPath id="clip-triangle-2">
<polygon points="0 15,310 15,330 0,350 15,1120 15,1120 370,0 370" />
</clipPath>
<clipPath id="clip-triangle-3">
<polygon points="0 15,540 15,560 0,580 15,1120 15,1120 370,0 370" />
</clipPath>
<clipPath id="clip-triangle-4">
<polygon points="0 15,770 15,790 0,810 15,1120 15,1120 370,0 370" />
</clipPath>
<clipPath id="clip-triangle-5">
<polygon points="0 15,995 15,1015 0,1035 15,1120 15,1120 370,0 370" />
</clipPath>
<linearGradient id="row-bg-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#e4dedf" />
<stop offset="100%" stop-color="#cbbcc4" />
</linearGradient>
</defs>
</svg>
<div class="sk-bangumi">
<div class="mine an-box animated fadeIn">
<ul class="list-inline an-ul">
<li>
<span data-src="/images/Bangumi/202204/d8ef46c0.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3288" data-bangumiindex="1"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2024/08/09 更新</div>
<a href="/Home/Bangumi/3288" target="_blank" class="an-text"
title="&#x5409;&#x4F0A;&#x5361;&#x54C7;">&#x5409;&#x4F0A;&#x5361;&#x54C7;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3288"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202407/997f06af.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3383" data-bangumiindex="2"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/08 更新</div>
<a href="/Home/Bangumi/3383" target="_blank" class="an-text"
title="&#x4F60;&#x4E0E;&#x6211;&#x6700;&#x540E;&#x7684;&#x6218;&#x573A;&#xFF0C;&#x4EA6;&#x6216;&#x662F;&#x4E16;&#x754C;&#x8D77;&#x59CB;&#x7684;&#x5723;&#x6218; &#x7B2C;&#x4E8C;&#x5B63;">&#x4F60;&#x4E0E;&#x6211;&#x6700;&#x540E;&#x7684;&#x6218;&#x573A;&#xFF0C;&#x4EA6;&#x6216;&#x662F;&#x4E16;&#x754C;&#x8D77;&#x59CB;&#x7684;&#x5723;&#x6218;
&#x7B2C;&#x4E8C;&#x5B63;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3383"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202501/c63dd1b9.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3515" data-bangumiindex="3"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/04/20 更新</div>
<a href="/Home/Bangumi/3515" target="_blank" class="an-text"
title="&#x51E0;&#x5206;&#x949F;&#x7684;&#x6B22;&#x547C;">&#x51E0;&#x5206;&#x949F;&#x7684;&#x6B22;&#x547C;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3515"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202501/d5a4b73b.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3526" data-bangumiindex="4"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/14 更新</div>
<a href="/Home/Bangumi/3526" target="_blank" class="an-text"
title="&#x6211;&#x5BB6;&#x6709;&#x4E2A;&#x963F;&#x5B85;&#x5973;&#x5FCD;&#x8005;">&#x6211;&#x5BB6;&#x6709;&#x4E2A;&#x963F;&#x5B85;&#x5973;&#x5FCD;&#x8005;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3526"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202501/2e430a10.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3530" data-bangumiindex="5"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/12 更新</div>
<a href="/Home/Bangumi/3530" target="_blank" class="an-text"
title="&#x836F;&#x5C4B;&#x5C11;&#x5973;&#x7684;&#x5462;&#x5583; &#x7B2C;&#x4E8C;&#x5B63;">&#x836F;&#x5C4B;&#x5C11;&#x5973;&#x7684;&#x5462;&#x5583;
&#x7B2C;&#x4E8C;&#x5B63;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3530"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
</ul>
</div>
<div class="row an-res-row-frame" style="display:none;"></div>
<div class="mine an-box animated fadeIn">
<ul class="list-inline an-ul">
<li>
<span data-src="/images/Bangumi/202501/424750fe.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3556" data-bangumiindex="1"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/14 更新</div>
<a href="/Home/Bangumi/3556" target="_blank" class="an-text"
title="&#x8131;&#x79BB;&#x4E86;A&#x7EA7;&#x961F;&#x4F0D;&#x7684;&#x6211;&#xFF0C;&#x548C;&#x4ECE;&#x524D;&#x7684;&#x5F92;&#x5F1F;&#x4EEC;&#x524D;&#x5F80;&#x8FF7;&#x5BAB;&#x6DF1;&#x5904;&#x3002;">&#x8131;&#x79BB;&#x4E86;A&#x7EA7;&#x961F;&#x4F0D;&#x7684;&#x6211;&#xFF0C;&#x548C;&#x4ECE;&#x524D;&#x7684;&#x5F92;&#x5F1F;&#x4EEC;&#x524D;&#x5F80;&#x8FF7;&#x5BAB;&#x6DF1;&#x5904;&#x3002;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3556"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/c0832100.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3573" data-bangumiindex="2"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/05 更新</div>
<a href="/Home/Bangumi/3573" target="_blank" class="an-text"
title="&#x4E00;&#x8D77;&#x52A0;&#x6CB9;&#x5427;">&#x4E00;&#x8D77;&#x52A0;&#x6CB9;&#x5427;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3573"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/f86c46e8.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3582" data-bangumiindex="3"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3582" target="_blank" class="an-text"
title="&#x5B89;&#x59AE;&#xB7;&#x96EA;&#x8389;">&#x5B89;&#x59AE;&#xB7;&#x96EA;&#x8389;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3582"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/3e3fe89e.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3583" data-bangumiindex="4"
data-showsubscribed="true"></span>
<div class="num-node text-center">6</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/14 更新</div>
<a href="/Home/Bangumi/3583" target="_blank" class="an-text"
title="&#x76F4;&#x81F3;&#x9B54;&#x5973;&#x6D88;&#x901D;">&#x76F4;&#x81F3;&#x9B54;&#x5973;&#x6D88;&#x901D;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3583"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/d92cb519.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3584" data-bangumiindex="5"
data-showsubscribed="true"></span>
<div class="num-node text-center">3</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/14 更新</div>
<a href="/Home/Bangumi/3584" target="_blank" class="an-text"
title="&#x5FCD;&#x8005;&#x4E0E;&#x6740;&#x624B;&#x4E8C;&#x4EBA;&#x7EC4;&#x7684;&#x65E5;&#x5E38;&#x751F;&#x6D3B;">&#x5FCD;&#x8005;&#x4E0E;&#x6740;&#x624B;&#x4E8C;&#x4EBA;&#x7EC4;&#x7684;&#x65E5;&#x5E38;&#x751F;&#x6D3B;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3584"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
</ul>
</div>
<div class="row an-res-row-frame" style="display:none;"></div>
<div class="mine an-box animated fadeIn">
<ul class="list-inline an-ul">
<li>
<span data-src="/images/Bangumi/202504/e33a7226.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3585" data-bangumiindex="1"
data-showsubscribed="true"></span>
<div class="num-node text-center">2</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/14 更新</div>
<a href="/Home/Bangumi/3585" target="_blank" class="an-text"
title="&#x673A;&#x52A8;&#x6218;&#x58EB;&#x9AD8;&#x8FBE; GQuuuuuuX">&#x673A;&#x52A8;&#x6218;&#x58EB;&#x9AD8;&#x8FBE;
GQuuuuuuX</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3585"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/a5d55f1e.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3586" data-bangumiindex="2"
data-showsubscribed="true"></span>
<div class="num-node text-center">3</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3586" target="_blank" class="an-text"
title="&#x6253;&#x4E86;300&#x5E74;&#x7684;&#x53F2;&#x83B1;&#x59C6;&#xFF0C;&#x4E0D;&#x77E5;&#x4E0D;&#x89C9;&#x5C31;&#x7EC3;&#x5230;&#x4E86;&#x6EE1;&#x7EA7; &#xFF5E;&#x5176;&#x4E8C;&#xFF5E;">&#x6253;&#x4E86;300&#x5E74;&#x7684;&#x53F2;&#x83B1;&#x59C6;&#xFF0C;&#x4E0D;&#x77E5;&#x4E0D;&#x89C9;&#x5C31;&#x7EC3;&#x5230;&#x4E86;&#x6EE1;&#x7EA7;
&#xFF5E;&#x5176;&#x4E8C;&#xFF5E;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3586"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/ff5c2429.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3587" data-bangumiindex="3"
data-showsubscribed="true"></span>
<div class="num-node text-center">3</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3587" target="_blank" class="an-text"
title="&#x9B54;&#x5973;&#x5B88;&#x62A4;&#x8005;">&#x9B54;&#x5973;&#x5B88;&#x62A4;&#x8005;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3587"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/81ebb5a9.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3591" data-bangumiindex="4"
data-showsubscribed="true"></span>
<div class="num-node text-center">2</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3591" target="_blank" class="an-text"
title="&#x4E61;&#x4E0B;&#x5927;&#x53D4;&#x6210;&#x4E3A;&#x5251;&#x5723;">&#x4E61;&#x4E0B;&#x5927;&#x53D4;&#x6210;&#x4E3A;&#x5251;&#x5723;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3591"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/d233a952.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3592" data-bangumiindex="5"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3592" target="_blank" class="an-text"
title="&#x6211;&#x662F;&#x661F;&#x9645;&#x56FD;&#x5BB6;&#x7684;&#x6076;&#x5FB7;&#x9886;&#x4E3B;&#xFF01;">&#x6211;&#x662F;&#x661F;&#x9645;&#x56FD;&#x5BB6;&#x7684;&#x6076;&#x5FB7;&#x9886;&#x4E3B;&#xFF01;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3592"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
</ul>
</div>
<div class="row an-res-row-frame" style="display:none;"></div>
<div class="mine an-box animated fadeIn">
<ul class="list-inline an-ul">
<li>
<span data-src="/images/Bangumi/202504/44e224ed.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3593" data-bangumiindex="1"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/09 更新</div>
<a href="/Home/Bangumi/3593" target="_blank" class="an-text"
title="&#x77AC;&#x95F4;&#x6CBB;&#x7597;&#x5374;&#x88AB;&#x89C6;&#x4E3A;&#x65E0;&#x7528;&#x800C;&#x88AB;&#x6D41;&#x653E;&#x7684;&#x5929;&#x624D;&#x6CBB;&#x7597;&#x5E08;&#xFF0C;&#x4EE5;&#x6697;&#x9ED1;&#x6CBB;&#x7597;&#x5E08;&#x7684;&#x8EAB;&#x4EFD;&#x5E78;&#x798F;&#x5730;&#x751F;&#x6D3B;&#x7740;">&#x77AC;&#x95F4;&#x6CBB;&#x7597;&#x5374;&#x88AB;&#x89C6;&#x4E3A;&#x65E0;&#x7528;&#x800C;&#x88AB;&#x6D41;&#x653E;&#x7684;&#x5929;&#x624D;&#x6CBB;&#x7597;&#x5E08;&#xFF0C;&#x4EE5;&#x6697;&#x9ED1;&#x6CBB;&#x7597;&#x5E08;&#x7684;&#x8EAB;&#x4EFD;&#x5E78;&#x798F;&#x5730;&#x751F;&#x6D3B;&#x7740;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3593"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/85659a01.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3594" data-bangumiindex="2"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3594" target="_blank" class="an-text"
title="&#x5916;&#x661F;&#x4EBA;&#x59C6;&#x59C6;">&#x5916;&#x661F;&#x4EBA;&#x59C6;&#x59C6;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3594"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/95afd8e9.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3598" data-bangumiindex="3"
data-showsubscribed="true"></span>
<div class="num-node text-center">2</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/14 更新</div>
<a href="/Home/Bangumi/3598" target="_blank" class="an-text"
title="&#x968F;&#x5174;&#x65C5;-That&#x27;s Journey-">&#x968F;&#x5174;&#x65C5;-That&#x27;s
Journey-</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3598"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/076c1094.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3599" data-bangumiindex="4"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3599" target="_blank" class="an-text"
title="&#x590F;&#x65E5;&#x53E3;&#x888B;">&#x590F;&#x65E5;&#x53E3;&#x888B;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3599"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/18802e7d.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3600" data-bangumiindex="5"
data-showsubscribed="true"></span>
<div class="num-node text-center">3</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/14 更新</div>
<a href="/Home/Bangumi/3600" target="_blank" class="an-text"
title="&#x672B;&#x65E5;&#x540E;&#x9152;&#x5E97;">&#x672B;&#x65E5;&#x540E;&#x9152;&#x5E97;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3600"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
</ul>
</div>
<div class="row an-res-row-frame" style="display:none;"></div>
<div class="mine an-box animated fadeIn">
<ul class="list-inline an-ul">
<li>
<span data-src="/images/Bangumi/202504/2e4096c4.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3601" data-bangumiindex="1"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3601" target="_blank" class="an-text"
title="&#x4E2D;&#x7985;&#x5BFA;&#x8001;&#x5E08;&#x7684;&#x7075;&#x602A;&#x8BB2;&#x4E49;&#x5B9E;&#x5F55; &#x8001;&#x5E08;&#x4F1A;&#x628A;&#x8C1C;&#x9898;&#x5168;&#x90FD;&#x89E3;&#x5F00;&#x7684;&#x3002;">&#x4E2D;&#x7985;&#x5BFA;&#x8001;&#x5E08;&#x7684;&#x7075;&#x602A;&#x8BB2;&#x4E49;&#x5B9E;&#x5F55;
&#x8001;&#x5E08;&#x4F1A;&#x628A;&#x8C1C;&#x9898;&#x5168;&#x90FD;&#x89E3;&#x5F00;&#x7684;&#x3002;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3601"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/2e97d6d8.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3602" data-bangumiindex="2"
data-showsubscribed="true"></span>
<div class="num-node text-center">2</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3602" target="_blank" class="an-text"
title="&#x7537;&#x5973;&#x4E4B;&#x95F4;&#x7684;&#x53CB;&#x60C5;&#x5B58;&#x5728;&#x5417;&#xFF1F;&#xFF08;&#x4E0D;&#xFF0C;&#x4E0D;&#x5B58;&#x5728;!!&#xFF09;">&#x7537;&#x5973;&#x4E4B;&#x95F4;&#x7684;&#x53CB;&#x60C5;&#x5B58;&#x5728;&#x5417;&#xFF1F;&#xFF08;&#x4E0D;&#xFF0C;&#x4E0D;&#x5B58;&#x5728;!!&#xFF09;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3602"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/e30f1861.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3603" data-bangumiindex="3"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3603" target="_blank" class="an-text"
title="&#x62DC;&#x6258;&#x8BF7;&#x7A7F;&#x4E0A;&#xFF0C;&#x9E70;&#x5CF0;&#x540C;&#x5B66;">&#x62DC;&#x6258;&#x8BF7;&#x7A7F;&#x4E0A;&#xFF0C;&#x9E70;&#x5CF0;&#x540C;&#x5B66;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3603"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/a2f98c24.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3604" data-bangumiindex="4"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/12 更新</div>
<a href="/Home/Bangumi/3604" target="_blank" class="an-text"
title="&#x8D5B;&#x9A6C;&#x5A18; &#x82A6;&#x6BDB;&#x7070;&#x59D1;&#x5A18;">&#x8D5B;&#x9A6C;&#x5A18;
&#x82A6;&#x6BDB;&#x7070;&#x59D1;&#x5A18;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3604"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/80d05785.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3605" data-bangumiindex="5"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3605" target="_blank" class="an-text"
title="&#x63A8;&#x7406;&#x8981;&#x5728;&#x665A;&#x9910;&#x540E;">&#x63A8;&#x7406;&#x8981;&#x5728;&#x665A;&#x9910;&#x540E;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3605"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
</ul>
</div>
<div class="row an-res-row-frame" style="display:none;"></div>
<div class="mine an-box animated fadeIn">
<ul class="list-inline an-ul">
<li>
<span data-src="/images/Bangumi/202504/6f86415a.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3606" data-bangumiindex="1"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3606" target="_blank" class="an-text"
title="&#x6447;&#x6EDA;&#x4E43;&#x662F;&#x6DD1;&#x5973;&#x7684;&#x7231;&#x597D;">&#x6447;&#x6EDA;&#x4E43;&#x662F;&#x6DD1;&#x5973;&#x7684;&#x7231;&#x597D;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3606"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/d731d60f.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3607" data-bangumiindex="2"
data-showsubscribed="true"></span>
<div class="num-node text-center">2</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3607" target="_blank" class="an-text"
title="&#x4E5D;&#x9F99;&#x5927;&#x4F17;&#x6D6A;&#x6F2B;">&#x4E5D;&#x9F99;&#x5927;&#x4F17;&#x6D6A;&#x6F2B;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3607"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/0bf31724.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3609" data-bangumiindex="3"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/10 更新</div>
<a href="/Home/Bangumi/3609" target="_blank" class="an-text"
title="&#x5723;&#x5973;&#x56E0;&#x592A;&#x8FC7;&#x5B8C;&#x7F8E;&#x4E0D;&#x591F;&#x53EF;&#x7231;&#x800C;&#x88AB;&#x5E9F;&#x9664;&#x5A5A;&#x7EA6;&#x5E76;&#x5356;&#x5230;&#x90BB;&#x56FD;">&#x5723;&#x5973;&#x56E0;&#x592A;&#x8FC7;&#x5B8C;&#x7F8E;&#x4E0D;&#x591F;&#x53EF;&#x7231;&#x800C;&#x88AB;&#x5E9F;&#x9664;&#x5A5A;&#x7EA6;&#x5E76;&#x5356;&#x5230;&#x90BB;&#x56FD;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3609"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/b41afc59.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3611" data-bangumiindex="4"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/11 更新</div>
<a href="/Home/Bangumi/3611" target="_blank" class="an-text"
title="&#x708E;&#x708E;&#x6D88;&#x9632;&#x961F; &#x53C1;&#x4E4B;&#x7AE0;">&#x708E;&#x708E;&#x6D88;&#x9632;&#x961F;
&#x53C1;&#x4E4B;&#x7AE0;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3611"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/fc6eaf43.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3612" data-bangumiindex="5"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/12 更新</div>
<a href="/Home/Bangumi/3612" target="_blank" class="an-text"
title="&#x6D4B;&#x4E0D;&#x51C6;&#x7684;&#x963F;&#x6CE2;&#x8FDE;&#x540C;&#x5B66; &#x7B2C;&#x4E8C;&#x5B63;">&#x6D4B;&#x4E0D;&#x51C6;&#x7684;&#x963F;&#x6CE2;&#x8FDE;&#x540C;&#x5B66;
&#x7B2C;&#x4E8C;&#x5B63;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3612"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
</ul>
</div>
<div class="row an-res-row-frame" style="display:none;"></div>
<div class="mine an-box animated fadeIn">
<ul class="list-inline an-ul">
<li>
<span data-src="/images/Bangumi/202504/3c0bd08c.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3613" data-bangumiindex="1"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/14 更新</div>
<a href="/Home/Bangumi/3613" target="_blank" class="an-text"
title="&#x9633;&#x5149;&#x9A6C;&#x8FBE;&#x68D2;&#x7403;&#x573A;&#xFF01;">&#x9633;&#x5149;&#x9A6C;&#x8FBE;&#x68D2;&#x7403;&#x573A;&#xFF01;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3613"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/18f01f9e.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3614" data-bangumiindex="2"
data-showsubscribed="true"></span>
<div class="num-node text-center">14</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/14 更新</div>
<a href="/Home/Bangumi/3614" target="_blank" class="an-text"
title="&#x7D2B;&#x4E91;&#x5BFA;&#x5BB6;&#x7684;&#x5144;&#x5F1F;&#x59D0;&#x59B9;">&#x7D2B;&#x4E91;&#x5BFA;&#x5BB6;&#x7684;&#x5144;&#x5F1F;&#x59D0;&#x59B9;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3614"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/0c4c9df3.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3615" data-bangumiindex="3"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/11 更新</div>
<a href="/Home/Bangumi/3615" target="_blank" class="an-text"
title="&#x7EC8;&#x672B;&#x8D77;&#x70B9;">&#x7EC8;&#x672B;&#x8D77;&#x70B9;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3615"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/096c19d0.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3617" data-bangumiindex="4"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/12 更新</div>
<a href="/Home/Bangumi/3617" target="_blank" class="an-text"
title="&#x7231;&#x6709;&#x4E9B;&#x6C89;&#x91CD;&#x7684;&#x9ED1;&#x6697;&#x7CBE;&#x7075;&#x4ECE;&#x5F02;&#x4E16;&#x754C;&#x8FFD;&#x8FC7;&#x6765;&#x4E86;">&#x7231;&#x6709;&#x4E9B;&#x6C89;&#x91CD;&#x7684;&#x9ED1;&#x6697;&#x7CBE;&#x7075;&#x4ECE;&#x5F02;&#x4E16;&#x754C;&#x8FFD;&#x8FC7;&#x6765;&#x4E86;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3617"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/143326f9.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3618" data-bangumiindex="5"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/10 更新</div>
<a href="/Home/Bangumi/3618" target="_blank" class="an-text"
title="&#x795E;&#x7EDF;&#x8BB0;">&#x795E;&#x7EDF;&#x8BB0;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3618"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
</ul>
</div>
<div class="row an-res-row-frame" style="display:none;"></div>
<div class="mine an-box animated fadeIn">
<ul class="list-inline an-ul">
<li>
<span data-src="/images/Bangumi/202504/c59f3187.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3620" data-bangumiindex="1"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3620" target="_blank" class="an-text"
title="&#x8BB0;&#x5FC6;&#x7F1D;&#x7EBF;">&#x8BB0;&#x5FC6;&#x7F1D;&#x7EBF;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3620"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/2b2ea518.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3621" data-bangumiindex="2"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/12 更新</div>
<a href="/Home/Bangumi/3621" target="_blank" class="an-text"
title="&#x5FEB;&#x85CF;&#x8D77;&#x6765;&#xFF01;&#x739B;&#x742A;&#x5A1C;&#x540C;&#x5B66;!!">&#x5FEB;&#x85CF;&#x8D77;&#x6765;&#xFF01;&#x739B;&#x742A;&#x5A1C;&#x540C;&#x5B66;!!</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3621"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/fddd0557.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3623" data-bangumiindex="3"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3623" target="_blank" class="an-text"
title="&#x65F6;&#x5149;&#x6D41;&#x901D;&#xFF0C;&#x996D;&#x83DC;&#x4F9D;&#x65E7;&#x7F8E;&#x5473;">&#x65F6;&#x5149;&#x6D41;&#x901D;&#xFF0C;&#x996D;&#x83DC;&#x4F9D;&#x65E7;&#x7F8E;&#x5473;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3623"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/cf6e83ba.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3624" data-bangumiindex="4"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3624" target="_blank" class="an-text"
title="&#x62C9;&#x6492;&#x8DEF;">&#x62C9;&#x6492;&#x8DEF;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3624"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/17a31d67.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3627" data-bangumiindex="5"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3627" target="_blank" class="an-text"
title="&#x641E;&#x7B11;&#x6F2B;&#x753B;&#x65E5;&#x548C;GO">&#x641E;&#x7B11;&#x6F2B;&#x753B;&#x65E5;&#x548C;GO</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3627"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
</ul>
</div>
<div class="row an-res-row-frame" style="display:none;"></div>
<div class="mine an-box animated fadeIn">
<ul class="list-inline an-ul">
<li>
<span data-src="/images/Bangumi/202504/ca36fa9e.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3628" data-bangumiindex="1"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/12 更新</div>
<a href="/Home/Bangumi/3628" target="_blank" class="an-text"
title="mono&#x5973;&#x5B69;">mono&#x5973;&#x5B69;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3628"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/af9a6139.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3632" data-bangumiindex="2"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/11 更新</div>
<a href="/Home/Bangumi/3632" target="_blank" class="an-text"
title="&#x5E72;&#x6742;&#x6D3B;&#x6211;&#x4E43;&#x6700;&#x5F3A;&#xFF5E;&#x5173;&#x4E8E;&#x539F;&#x82F1;&#x96C4;&#x961F;&#x4F0D;&#x7684;&#x6742;&#x5F79;&#x4EBA;&#x5458;&#xFF0C;&#x5B9E;&#x9645;&#x4E0A;&#x9664;&#x4E86;&#x6218;&#x6597;&#x80FD;&#x529B;&#x5916;&#x5168;&#x662F;SSS&#x7684;&#x6545;&#x4E8B;&#xFF5E;">&#x5E72;&#x6742;&#x6D3B;&#x6211;&#x4E43;&#x6700;&#x5F3A;&#xFF5E;&#x5173;&#x4E8E;&#x539F;&#x82F1;&#x96C4;&#x961F;&#x4F0D;&#x7684;&#x6742;&#x5F79;&#x4EBA;&#x5458;&#xFF0C;&#x5B9E;&#x9645;&#x4E0A;&#x9664;&#x4E86;&#x6218;&#x6597;&#x80FD;&#x529B;&#x5916;&#x5168;&#x662F;SSS&#x7684;&#x6545;&#x4E8B;&#xFF5E;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3632"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/7ea16a61.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3633" data-bangumiindex="3"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/04/03 更新</div>
<a href="/Home/Bangumi/3633" target="_blank" class="an-text"
title="&#x8FDB;&#x5165;&#x82B1;&#x56ED;">&#x8FDB;&#x5165;&#x82B1;&#x56ED;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3633"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/3400d7c6.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3635" data-bangumiindex="4"
data-showsubscribed="true"></span>
<div class="num-node text-center">3</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3635" target="_blank" class="an-text"
title="&#x5C0F;&#x5E02;&#x6C11;&#x7CFB;&#x5217; &#x7B2C;&#x4E8C;&#x5B63;">&#x5C0F;&#x5E02;&#x6C11;&#x7CFB;&#x5217;
&#x7B2C;&#x4E8C;&#x5B63;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3635"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/62a30c3f.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3636" data-bangumiindex="5"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/04/05 更新</div>
<a href="/Home/Bangumi/3636" target="_blank" class="an-text"
title="The Star Seekers &#x661F;&#x3092;&#x8FFD;&#x3046;&#x5C11;&#x5E74;&#x305F;&#x3061;">The
Star Seekers &#x661F;&#x3092;&#x8FFD;&#x3046;&#x5C11;&#x5E74;&#x305F;&#x3061;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3636"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
</ul>
</div>
<div class="row an-res-row-frame" style="display:none;"></div>
<div class="mine an-box animated fadeIn">
<ul class="list-inline an-ul">
<li>
<span data-src="/images/Bangumi/202504/ffce0184.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3641" data-bangumiindex="1"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3641" target="_blank" class="an-text"
title="&#x518D;&#x89C1;&#xFF0C;&#x5730;&#x7403; &#x7B2C;&#x4E8C;&#x5B63;">&#x518D;&#x89C1;&#xFF0C;&#x5730;&#x7403;
&#x7B2C;&#x4E8C;&#x5B63;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3641"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/716c2ff5.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3642" data-bangumiindex="2"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/11 更新</div>
<a href="/Home/Bangumi/3642" target="_blank" class="an-text"
title="&#x6BCF;&#x65E5;&#x7537;&#x516C;&#x5173;">&#x6BCF;&#x65E5;&#x7537;&#x516C;&#x5173;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3642"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/052595b4.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3644" data-bangumiindex="3"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/11 更新</div>
<a href="/Home/Bangumi/3644" target="_blank" class="an-text"
title="&#x8389;&#x53EF;&#x4E3D;&#x4E1D;&#xFF1A;&#x53CB;&#x8C0A;&#x662F;&#x65F6;&#x95F4;&#x7684;&#x7A83;&#x8D3C;">&#x8389;&#x53EF;&#x4E3D;&#x4E1D;&#xFF1A;&#x53CB;&#x8C0A;&#x662F;&#x65F6;&#x95F4;&#x7684;&#x7A83;&#x8D3C;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3644"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/be7f8aef.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3646" data-bangumiindex="4"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/12 更新</div>
<a href="/Home/Bangumi/3646" target="_blank" class="an-text"
title="&#x6218;&#x961F;&#x5927;&#x5931;&#x683C; &#x7B2C;&#x4E8C;&#x5B63;">&#x6218;&#x961F;&#x5927;&#x5931;&#x683C;
&#x7B2C;&#x4E8C;&#x5B63;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3646"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
</ul>
</div>
<div class="row an-res-row-frame" style="display:none;"></div>
</div>

Before

Width:  |  Height:  |  Size: 54 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,564 +0,0 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="keywords" content="新番,动漫,动漫下載,新番下载,animation,bangumi,动画,蜜柑计划,Mikan Project" />
<meta name="description" content="蜜柑计划:新一代的动漫下载站" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- 若用户有Google Chrome Frame,那么ie浏览时让IE使用chrome内核 -->
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<!-- 若是双核浏览器,默认webkit渲染(chrome) -->
<meta name="renderer" content="webkit">
<title>Mikan Project - &#x7528;&#x6237;&#x767B;&#x5F55;</title>
<!-- here put import css lib -->
<link rel="stylesheet"
href="/lib/bootstrap/dist/css/bootstrap.min.css?v=7s5uDGW3AHqw6xtJmNNtr-OBRJUlgkNJEo78P4b0yRw" />
<link rel="stylesheet"
href="/lib/font-awesome/css/font-awesome.min.css?v=3dkvEK0WLHRJ7_Csr0BZjAWxERc5WH7bdeUya2aXxdU" />
<link rel="stylesheet" href="/css/thirdparty.min.css?v=c2SZy6n-55iljz60XCAALXejEZvjc43kgwamU5DAYUU" />
<link rel="stylesheet" href="/css/animate.min.css?v=w_eXqGX0NdMPQ0LZNhdQ8B-DQMYAxelvLoIP39dzmus" />
<link rel="stylesheet" href="/css/mikan.min.css?v=aupBMgBgKRB5chTb5fl8lvHpN3OqX67_gKg3lXZewRw" />
<script src="/lib/jquery/dist/jquery.min.js?v=BbhdlvQf_xTY9gja0Dq3HiwQF8LaCRTXxZKRutelT44"></script>
<script src="/lib/bootstrap/dist/js/bootstrap.min.js?v=KXn5puMvxCw-dAYznun-drMdG1IFl3agK0p_pqT9KAo"></script>
<script src="/js/thirdparty.min.js?v=NsK_w5fw7Nm4ZPm4eZDgsivasZNgT6ArhIjmj-bRnR0"></script>
<script src="/js/darkreader.min.js?v=Lr_8XODLEDSPtT6LqaeLKzREs4jocJUzV8HvQPItIic"></script>
<script src="/js/ScrollMagic.min.js?v=1xuIM3UJWEZX_wWN9zrA8W7CWukfsMaEqb759CeHo3U"></script>
<script src="/js/jquery.ScrollMagic.min.js?v=SyygQh9gWWfvyS13QwI0SKGAQyHDachlaigiK4X59iw"></script>
<link rel="icon" href="/images/favicon.ico?v=2" />
<link rel="apple-touch-icon" href="\Images\apple-touch-icon.png">
<link rel="apple-touch-icon" sizes="152x152" href="\Images\apple-touch-icon-152x152.png">
<link rel="apple-touch-icon" sizes="180x180" href="\Images\apple-touch-icon-180x180.png">
<link rel="apple-touch-icon" sizes="144x144" href="\Images\apple-touch-icon-144x144.png">
<script>
(function (i, s, o, g, r, a, m) {
i['GoogleAnalyticsObject'] = r; i[r] = i[r] || function () {
(i[r].q = i[r].q || []).push(arguments)
}, i[r].l = 1 * new Date(); a = s.createElement(o),
m = s.getElementsByTagName(o)[0]; a.async = 1; a.src = g; m.parentNode.insertBefore(a, m)
})(window, document, 'script', '//www.google-analytics.com/analytics.js', 'ga');
ga('create', 'UA-8911610-8', 'auto');
ga('send', 'pageview');
</script>
</head>
<body class="main">
<div id="sk-header" class="hidden-xs hidden-sm">
<div id="sk-top-nav" class="container">
<a id="logo" href="/" style="width:205px;"><img id="mikan-pic" src="/images/mikan-pic.png" /><img
src="/images/mikan-text.svg" style="height:30px;" /></a>
<div id="nav-list">
<ul class="list-inline nav-ul">
<li class="">
<div class="sk-col"><a href="/"><i class="fa fa-home fa-lg"></i>主页</a></div>
</li>
<li class="">
<div class="sk-col"><a href="/Home/MyBangumi"><i class="fa fa-rss fa-lg"></i>订阅</a></div>
</li>
<li class="">
<div class="sk-col"><a href="/Home/Classic"><i class="fa fa-slack fa-lg"></i>列表</a></div>
</li>
</ul>
</div>
<div class="search-form">
<form method="get" action="/Home/Search">
<div class="form-group has-feedback">
<label for="search" class="sr-only">搜索</label>
<input type="text" class="form-control input-sm" name="searchstr" id="header-search"
placeholder="搜索">
<span class="glyphicon glyphicon-search form-control-feedback"></span>
</div>
</form>
</div>
<section id="login">
<div id="user-login" class="pull-right">
<a href="/Account/Register" class="text-right">注册</a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a onclick="ToggleActive(this)" class="text-right" data-toggle="popover-x"
data-target="#login-popover" data-placement="bottom bottom-right" rel="popover">登录</a>
<form action="/Account/Login?ReturnUrl=%2FAccount%2FLogin" class="form-vertical" method="post">
<div id="login-popover" class="popover popover-default">
<div class="arrow"></div>
<div id="login-popover-conent">
<div id="login-popover-input">
<div id="login-popover-div-username">
<img src="/images/user-name_login_icon.png" />
<input type="text" placeholder="用户名" id="login-popover-input-username"
name="UserName" />
</div>
<div id="login-popover-div-password">
<img src="/images/password_login_icon.png" style="margin-left:3px;" />
<input type="password" placeholder="密码" id="login-popover-input-password"
name="Password" />
</div>
</div>
<button id="login-popover-submit" type="submit"
class="btn">登&nbsp;&nbsp;&nbsp;</button>
<div class="checkbox" id="login-popover-password">
<label id="login-popover-remember-password"><input type="checkbox" value="true"
name="RememberMe"><input type="hidden" value="false"
name="RememberMe">记住密码</label>
<div id="login-popover-forget-password" class="pull-right"><a
href="/Account/ForgotPassword" class="forget-password">忘记密码</a></div>
</div>
<a id="login-popover-create-account">还没有账号?赶紧来注册一个吧~</a>
</div>
</div>
<input name="__RequestVerificationToken" type="hidden"
value="CfDJ8MyNMqFNaC9JmJW13PvY-91NCe3U_rFij5nf6ni2NtxaxhQNSuonDbK7198YMfFdErEyhk-CHiByyDQaq371N3GUx0c8Xma0F0F2J2UQaszZgfjT5vxTV4O4viF6YoPDWMO2yLbeN7ok83_uz1DD-nU" />
</form>
</div>
<script>
var AdvancedSubscriptionEnabled = false;
</script>
</section>
</div>
<div class="ribbon">
<span class="ribbon-color1"></span>
<span class="ribbon-color2"></span>
<span class="ribbon-color3"></span>
<span class="ribbon-color4"></span>
<span class="ribbon-color5"></span>
<span class="ribbon-color6"></span>
<span class="ribbon-color7"></span>
</div>
</div>
<div class="m-home-nav hidden-lg hidden-md" id="sk-mobile-header">
<div class="m-home-tool-left clickable" data-toggle="modal" data-target="#modal-nav">
<i class="fa fa-bars" aria-hidden="true"></i>
</div>
<div class="m-home-tool-left"></div>
<div style="text-align: center; height:100%;flex:1;">
<a href="/" style="text-decoration:none">
<img src="/images/mikan-pic.png" style="height: 3rem;margin-top: 0.5rem;">
<img src="/images/mikan-text.png" style="height: 1.5rem;margin-top: 0.5rem;">
</a>
</div>
<div class="m-home-tool-right clickable" data-toggle="modal" data-target="#modal-login">
<i class="fa fa-user" aria-hidden="true" style="margin-right: 1rem;"></i>
</div>
<div class="m-home-tool-right clickable" onclick="ShowNavSearch()">
<i class="fa fa-search" aria-hidden="true"></i>
</div>
</div>
<div class="m-nav-search" style="width: 100%;">
<div style="flex: 1;">
<form method="get" action="/Home/Search">
<div class="input-group">
<span class="input-group-addon" id="sizing-addon1" style="border: none;background-color: white;">
<i class="fa fa-search" aria-hidden="true"></i>
</span>
<input type="text" class="form-control" placeholder="搜索" name="searchstr"
aria-describedby="sizing-addon1" style="border: none;font-size:16px;">
</div>
</form>
</div>
<div style="width: 4rem;" onclick="HideNavSearch()">
<span style="font-size: 1.25rem;">取消</span>
</div>
</div>
<style>
@media only screen and (min-device-width : 768px) {
html,
body {
overflow: hidden;
}
}
</style>
<div id="account-bg-wrapper" class="hidden-sm hidden-xs">
<div class="logmod">
<div class="logmod__wrapper">
<div class="logmod__container">
<ul class="logmod__tabs">
<li data-tabtar="lgm-1"><a href="#">Mikan 账号注册</a></li>
<li data-tabtar="lgm-2"><a href="#">登录</a></li>
</ul>
<div class="logmod__tab-wrapper">
<div class="logmod__tab lgm-1">
<div class="logmod__heading">
<span class="logmod__heading-subtitle"></span>
</div>
<div class="logmod__form">
<p style="color:red" class="js-login-error">
&#x767B;&#x5F55;&#x5931;&#x8D25;&#xFF0C;&#x8BF7;&#x91CD;&#x8BD5;.</p>
<form action="/Account/Register?ReturnUrl=%2FAccount%2FManage" class="simform"
id="registerForm" method="post">
<div class="logmod__inputs full ">
<input type="text" class="logmod-input-control" placeholder="用户名*"
name="UserName" />
</div>
<div class="logmod__inputs full">
<input type="password" class="logmod-input-control" placeholder="设置密码*"
name="Password" id="register-password" />
</div>
<div class="logmod__inputs full">
<input type="password" class="logmod-input-control" placeholder="确认密码*"
name="ConfirmPassword" />
</div>
<div class="logmod__inputs full">
<input type="text" class="logmod-input-control" placeholder="设置邮箱*"
name="Email" />
</div>
<div class="logmod__inputs full">
<input type="text" class="logmod-input-control" placeholder="QQ" name="QQ" />
</div>
<button class="logmod-submit btn" type="submit"
value="Register">注&nbsp;&nbsp;&nbsp;</button>
<div class="checkbox" id="logmod-password">
<div id="logmod-forget-password" class="pull-right"><a
href="/Account/ForgotPassword" class="forget-password">忘记密码</a></div>
</div>
<input name="__RequestVerificationToken" type="hidden"
value="CfDJ8MyNMqFNaC9JmJW13PvY-91NCe3U_rFij5nf6ni2NtxaxhQNSuonDbK7198YMfFdErEyhk-CHiByyDQaq371N3GUx0c8Xma0F0F2J2UQaszZgfjT5vxTV4O4viF6YoPDWMO2yLbeN7ok83_uz1DD-nU" />
</form>
</div>
</div>
<div class="logmod__tab lgm-2">
<div class="logmod__heading">
<span class="logmod__heading-subtitle">Hi欢迎回来</span>
</div>
<div class="logmod__form">
<p style="color:red" class="js-login-error">
&#x767B;&#x5F55;&#x5931;&#x8D25;&#xFF0C;&#x8BF7;&#x91CD;&#x8BD5;.</p>
<form action="/Account/Login?ReturnUrl=%2FAccount%2FManage" class="simform"
id="loginForm" method="post">
<div class="logmod__inputs full left-addon">
<img src="/images/user-name_login_icon.png" class="logmod-icon" />
<input type="text" class="logmod-input-control" placeholder="用户名"
name="UserName" />
</div>
<div class="logmod__inputs full left-addon">
<img src="/images/password_login_icon.png" class="logmod-icon password" />
<input type="password" class="logmod-input-control" placeholder="密码"
name="Password" />
</div>
<button class="logmod-submit btn" type="submit"
value="Log in">登&nbsp;&nbsp;&nbsp;</button>
<div class="checkbox" id="logmod-password">
<label id="logmod-remember-password"><input type="checkbox" value="true"
name="RememberMe"><input type="hidden" value="false"
name="RememberMe">记住密码</label>
<div id="logmod-forget-password" class="pull-right"><a
href="/Account/ForgotPassword" class="forget-password">忘记密码</a></div>
</div>
<input name="__RequestVerificationToken" type="hidden"
value="CfDJ8MyNMqFNaC9JmJW13PvY-91NCe3U_rFij5nf6ni2NtxaxhQNSuonDbK7198YMfFdErEyhk-CHiByyDQaq371N3GUx0c8Xma0F0F2J2UQaszZgfjT5vxTV4O4viF6YoPDWMO2yLbeN7ok83_uz1DD-nU" />
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
$(document).ready(function () {
$.validator.addMethod("username", function (value, element) {
return this.optional(element) || /^[\u4e00-\u9fa5_a-zA-Z0-9_]{3,15}$/i.test(value);
}, "用户名只能使用中英文数字和下划线长度请控制在3-15字节以内");
$.validator.addMethod("usernameTaken", function (value, element) {
var valid = true;
$.ajax({
type: "POST",
url: '/Account/VerifyUserName',
data: JSON.stringify(value),
async: false,
error: function (XMLHttpRequest, textStatus, errorThrown) {
//alert("Request: " + XMLHttpRequest.toString() + "\n\nStatus: " + textStatus + "\n\nError: " + errorThrown);
},
success: function (data) {
valid = data.nottaken;
}
});
return valid;
}, "用户名已经被使用");
$("#registerForm").validate({
rules: {
UserName: {
required: true,
username: "用户名只能使用中英文数字和下划线长度请控制在3-15字节以内",
usernameTaken: "用户名已经被使用"
},
Password: {
required: true,
minlength: 6
},
ConfirmPassword: {
equalTo: "#register-password"
},
Email: {
required: true,
email: true
}
},
messages: {
UserName: {
required: "请输入用户名",
username: "用户名只能使用中英文数字和下划线长度请控制在3-15字节以内",
usernameTaken: "用户名已经被使用"
},
Password: {
required: "请输入密码",
minlength: "密码设置错误密码长度必须大于6位"
},
ConfirmPassword: {
equalTo: "两次输入的密码不一致,请再输入一次您之前输入的密码"
},
Email: {
required: "请输入Email地址",
email: "提供的Email地址无效请检查并重试"
}
}
});
$("#loginForm").validate({
rules: {
UserName: {
required: true,
username: "用户名只能使用中英文数字和下划线长度请控制在3-15字节以内"
},
Password: {
required: true,
minlength: 6
}
},
messages: {
UserName: {
required: "请输入用户名",
username: "用户名只能使用中英文数字和下划线长度请控制在3-15字节以内"
},
Password: {
required: "请输入密码",
minlength: "密码设置错误密码长度必须大于6位"
}
}
});
});
</script>
<div style="margin: auto;width:100%;height:85vh;" class="hidden-lg hidden-md">
<div class="m-login">
<div class="m-tool-title" style="padding-top: 7rem; color:#555;">
登陆mikan账号
</div>
<div style="text-align: center;margin-top: 2rem;">
<img src="/images/mikan-pic.png" style="width: 6rem;">
</div>
<p style="color:red; margin-left: 2.1rem;" class="m-login-error">
&#x767B;&#x5F55;&#x5931;&#x8D25;&#xFF0C;&#x8BF7;&#x91CD;&#x8BD5;.</p>
<form action="/Account/Login?ReturnUrl=%2FAccount%2FManage" id="mobileLoginForm" method="post">
<div id="mobileLoginInput">
<input type="text" class="form-control" aria-label="..." placeholder="用户名" name="UserName">
<input type="password" class="form-control" aria-label="..." placeholder="密码" name="Password">
</div>
<button class="form-control" type="submit">登录</button>
<input name="__RequestVerificationToken" type="hidden"
value="CfDJ8MyNMqFNaC9JmJW13PvY-91NCe3U_rFij5nf6ni2NtxaxhQNSuonDbK7198YMfFdErEyhk-CHiByyDQaq371N3GUx0c8Xma0F0F2J2UQaszZgfjT5vxTV4O4viF6YoPDWMO2yLbeN7ok83_uz1DD-nU" />
</form>
<div class="m-goto-registry">
<a href="/Account/Register" class="w-other-c" style="color:#3bc0c3">立即注册</a>
</div>
</div>
</div>
<script>
$(document).ready(function () {
$("#mobileLoginForm").validate({
rules: {
UserName: {
required: true,
},
Password: {
required: true,
}
},
messages: {
UserName: {
required: "用户名或密码错误",
},
Password: {
required: "用户名或密码错误",
}
},
groups: {
username: "UserName Password"
},
errorPlacement: function (error, element) {
error.insertAfter("#mobileLoginInput");
},
errorClass: "m-login-error"
});
});
</script>
<script src="/lib/jquery-validation/dist/jquery.validate.min.js"></script>
<script src="/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>
<script>
$(document).ready(function () {
LoginModalController.initialize(1);
});
</script>
<div class="modal modal-fullscreen fade" id="modal-nav" tabindex="-1" role="dialog" aria-labelledby="myModalLabel"
aria-hidden="true" style="background-color:#3bc0c3;">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-body" style="margin: auto;width:100%;">
<div class="m-tool">
<span class="m-close clickable"><i class="fa fa-times" aria-hidden="true" data-toggle="modal"
data-target="#modal-nav"></i></span>
<div class="m-tool-toolbar">
<img src="/images/mikan-pic.png" style="width: 3rem;">
<img src="/images/mikan-text.png" style="width: 7rem;">
</div>
<div class="m-tool-list">
<ul>
<li><a href="/" class="link">主页</a></li>
<li class="m-tool-search-change"><a href="/Home/MyBangumi" class="link">订阅</a></li>
<li onclick="tool.clickSearch()" class="m-tool-search-change">
<i class="fa fa-search" aria-hidden="true"></i>&nbsp;&nbsp;搜索站内
</li>
<li class="m-tool-search-input">
<form method="get" action="/Home/Search">
<div style="display: flex;height: 100%;">
<input type="text" class="form-control" name="searchstr"
style="font-size:16px;" />
<span style="width: 5rem;" onclick="tool.resetSearch()">取消</span>
</div>
</form>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal modal-fullscreen fade" id="modal-login" tabindex="-1" role="dialog" aria-labelledby="myModalLabel"
aria-hidden="true" style="background-color:#edf1f2;">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-body" style="margin: auto;width:100%;height:85vh;">
<div class="m-login">
<span class="m-left clickable"><i class="fa fa-angle-left" aria-hidden="true"
data-toggle="modal" data-target="#modal-login"></i></span>
<div class="m-tool-title">
登陆mikan账号
</div>
<div style="text-align: center;margin-top: 2rem;">
<img src="/images/mikan-pic.png" style="width: 6rem;">
</div>
<form action="/Account/Login?ReturnUrl=%2FAccount%2FLogin" method="post">
<div>
<input type="text" class="form-control" aria-label="..." placeholder="用户名"
name="UserName">
<input type="password" class="form-control" aria-label="..." placeholder="密码"
name="Password">
</div>
<button class="form-control" type="submit">登录</button>
<input name="__RequestVerificationToken" type="hidden"
value="CfDJ8MyNMqFNaC9JmJW13PvY-91NCe3U_rFij5nf6ni2NtxaxhQNSuonDbK7198YMfFdErEyhk-CHiByyDQaq371N3GUx0c8Xma0F0F2J2UQaszZgfjT5vxTV4O4viF6YoPDWMO2yLbeN7ok83_uz1DD-nU" />
</form>
<div class="m-goto-registry">
<a href="/Account/Register" class="w-other-c" style="color:#3bc0c3">立即注册</a>
</div>
</div>
</div>
</div>
</div>
</div>
<footer class="footer hidden-xs hidden-sm">
<div id="sk-footer" class="container text-center">
<div>Powered by Mikan Project <a href="/Home/Contact" target="_blank">联系我们</a></div>
<div>Cooperate by PlaymateCat@Lisa</div>
</div>
</footer>
<script>
var tool = {};
(function () {
var inputPEl = $('.m-tool-search-input');
var inputEl = inputPEl.find('input');
var changeEl = $('.m-tool-search-change');
inputPEl.hide();
tool.clickSearch = clickSearch;
tool.resetSearch = resetSearch;
function clickSearch() {
changeEl.hide();
inputPEl.show();
inputEl.focus();
}
function resetSearch() {
changeEl.show();
inputPEl.hide();
inputEl.val('');
}
})();
</script>
<script>
var pageUtil;
(function () {
pageUtil = {
isMobile: isMobile
};
function isMobile() {
var check = false;
(function (a) {
if (/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a) || /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0, 4))) check = true;
})(navigator.userAgent || navigator.vendor || window.opera);
return check;
}
})();
//detect if page is mobile
if (pageUtil.isMobile()) {
document.getElementsByTagName('html')[0].style['font-size'] = window.innerWidth / 32 + 'px';
}
</script>
</body>
<!-- here put your own javascript -->
<script src="/js/mikan.min.js?v=7USd_hfRE7KH46vQBdF29boa3ENWKMVFRTyD9a8XEDg"></script>
</html>

View File

@ -0,0 +1,641 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="keywords" content="新番,动漫,动漫下載,新番下载,animation,bangumi,动画,蜜柑计划,Mikan Project" />
<meta name="description" content="蜜柑计划:新一代的动漫下载站" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- 若用户有Google Chrome Frame,那么ie浏览时让IE使用chrome内核 -->
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<!-- 若是双核浏览器,默认webkit渲染(chrome) -->
<meta name="renderer" content="webkit">
<title>Mikan Project - &#x6211;&#x7684;&#x756A;&#x7EC4;</title>
<!-- here put import css lib -->
<link rel="stylesheet" href="/lib/bootstrap/dist/css/bootstrap.min.css?v=7s5uDGW3AHqw6xtJmNNtr-OBRJUlgkNJEo78P4b0yRw" />
<link rel="stylesheet" href="/lib/font-awesome/css/font-awesome.min.css?v=3dkvEK0WLHRJ7_Csr0BZjAWxERc5WH7bdeUya2aXxdU" />
<link rel="stylesheet" href="/css/thirdparty.min.css?v=c2SZy6n-55iljz60XCAALXejEZvjc43kgwamU5DAYUU" />
<link rel="stylesheet" href="/css/animate.min.css?v=w_eXqGX0NdMPQ0LZNhdQ8B-DQMYAxelvLoIP39dzmus" />
<link rel="stylesheet" href="/css/mikan.min.css?v=aupBMgBgKRB5chTb5fl8lvHpN3OqX67_gKg3lXZewRw" />
<script src="/lib/jquery/dist/jquery.min.js?v=BbhdlvQf_xTY9gja0Dq3HiwQF8LaCRTXxZKRutelT44"></script>
<script src="/lib/bootstrap/dist/js/bootstrap.min.js?v=KXn5puMvxCw-dAYznun-drMdG1IFl3agK0p_pqT9KAo"></script>
<script src="/js/thirdparty.min.js?v=NsK_w5fw7Nm4ZPm4eZDgsivasZNgT6ArhIjmj-bRnR0"></script>
<script src="/js/darkreader.min.js?v=Lr_8XODLEDSPtT6LqaeLKzREs4jocJUzV8HvQPItIic"></script>
<script src="/js/ScrollMagic.min.js?v=1xuIM3UJWEZX_wWN9zrA8W7CWukfsMaEqb759CeHo3U"></script>
<script src="/js/jquery.ScrollMagic.min.js?v=SyygQh9gWWfvyS13QwI0SKGAQyHDachlaigiK4X59iw"></script>
<link rel="icon" href="/images/favicon.ico?v=2" />
<link rel="apple-touch-icon" href="\Images\apple-touch-icon.png">
<link rel="apple-touch-icon" sizes="152x152" href="\Images\apple-touch-icon-152x152.png">
<link rel="apple-touch-icon" sizes="180x180" href="\Images\apple-touch-icon-180x180.png">
<link rel="apple-touch-icon" sizes="144x144" href="\Images\apple-touch-icon-144x144.png">
<script>
(function (i, s, o, g, r, a, m) {
i['GoogleAnalyticsObject'] = r; i[r] = i[r] || function () {
(i[r].q = i[r].q || []).push(arguments)
}, i[r].l = 1 * new Date(); a = s.createElement(o),
m = s.getElementsByTagName(o)[0]; a.async = 1; a.src = g; m.parentNode.insertBefore(a, m)
})(window, document, 'script', '//www.google-analytics.com/analytics.js', 'ga');
ga('create', 'UA-8911610-8', 'auto');
ga('send', 'pageview');
</script>
</head>
<body class="main">
<div id="sk-header" class="hidden-xs hidden-sm">
<div id="sk-top-nav" class="container">
<a id="logo" href="/" style="width:205px;"><img id="mikan-pic" src="/images/mikan-pic.png" /><img src="/images/mikan-text.svg" style="height:30px;" /></a>
<div id="nav-list">
<ul class="list-inline nav-ul">
<li class="">
<div class="sk-col"><a href="/"><i class="fa fa-home fa-lg"></i>主页</a></div>
</li>
<li class="active">
<div class="sk-col"><a href="/Home/MyBangumi"><i class="fa fa-rss fa-lg"></i>订阅</a></div>
</li>
<li class="">
<div class="sk-col"><a href="/Home/Classic"><i class="fa fa-slack fa-lg"></i>列表</a></div>
</li>
</ul>
</div>
<div class="search-form">
<form method="get" action="/Home/Search">
<div class="form-group has-feedback">
<label for="search" class="sr-only">搜索</label>
<input type="text" class="form-control input-sm" name="searchstr" id="header-search" placeholder="搜索">
<span class="glyphicon glyphicon-search form-control-feedback"></span>
</div>
</form>
</div>
<section id="login">
<div id="user-login" class="pull-right">
<a href="/Account/Register" class="text-right">注册</a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a onclick="ToggleActive(this)" class="text-right" data-toggle="popover-x" data-target="#login-popover" data-placement="bottom bottom-right" rel="popover">登录</a>
<form action="/Account/Login?ReturnUrl=%2FHome%2FMyBangumi" class="form-vertical" method="post"> <div id="login-popover" class="popover popover-default">
<div class="arrow"></div>
<div id="login-popover-conent">
<div id="login-popover-input">
<div id="login-popover-div-username">
<img src="/images/user-name_login_icon.png" />
<input type="text" placeholder="用户名" id="login-popover-input-username" name="UserName" />
</div>
<div id="login-popover-div-password">
<img src="/images/password_login_icon.png" style="margin-left:3px;" />
<input type="password" placeholder="密码" id="login-popover-input-password" name="Password" />
</div>
</div>
<button id="login-popover-submit" type="submit" class="btn">&nbsp;&nbsp;&nbsp;</button>
<div class="checkbox" id="login-popover-password">
<label id="login-popover-remember-password"><input type="checkbox" value="true" name="RememberMe"><input type="hidden" value="false" name="RememberMe">记住密码</label>
<div id="login-popover-forget-password" class="pull-right"><a href="/Account/ForgotPassword" class="forget-password">忘记密码</a></div>
</div>
<a id="login-popover-create-account">还没有账号?赶紧来注册一个吧~</a>
</div>
</div>
<input name="__RequestVerificationToken" type="hidden" value="CfDJ8MyNMqFNaC9JmJW13PvY-93GWOqu6SxfgR0S2fsWVSk41_SQEknnNNxKBZ6pMaRs2yMRKWarMeS2av33qkf82qO9KD9P_gm2ovTbvQyHwNtlRoBJBF1JSt_ZAdLZnvfTjfJ8q_Ih0ADcAt3lwJXdPxQ" /></form> </div>
<script>
var AdvancedSubscriptionEnabled = false;
</script>
</section>
</div>
<div class="ribbon">
<span class="ribbon-color1"></span>
<span class="ribbon-color2"></span>
<span class="ribbon-color3"></span>
<span class="ribbon-color4"></span>
<span class="ribbon-color5"></span>
<span class="ribbon-color6"></span>
<span class="ribbon-color7"></span>
</div>
</div>
<div class="m-home-nav hidden-lg hidden-md" id="sk-mobile-header">
<div class="m-home-tool-left clickable" data-toggle="modal" data-target="#modal-nav">
<i class="fa fa-bars" aria-hidden="true"></i>
</div>
<div class="m-home-tool-left"></div>
<div style="text-align: center; height:100%;flex:1;">
<a href="/" style="text-decoration:none">
<img src="/images/mikan-pic.png" style="height: 3rem;margin-top: 0.5rem;">
<img src="/images/mikan-text.png" style="height: 1.5rem;margin-top: 0.5rem;">
</a>
</div>
<div class="m-home-tool-right clickable" data-toggle="modal" data-target="#modal-login">
<i class="fa fa-user" aria-hidden="true" style="margin-right: 1rem;"></i>
</div>
<div class="m-home-tool-right clickable" onclick="ShowNavSearch()">
<i class="fa fa-search" aria-hidden="true"></i>
</div>
</div>
<div class="m-nav-search" style="width: 100%;">
<div style="flex: 1;">
<form method="get" action="/Home/Search">
<div class="input-group">
<span class="input-group-addon" id="sizing-addon1" style="border: none;background-color: white;">
<i class="fa fa-search" aria-hidden="true"></i>
</span>
<input type="text" class="form-control" placeholder="搜索" name="searchstr" aria-describedby="sizing-addon1" style="border: none;font-size:16px;">
</div>
</form>
</div>
<div style="width: 4rem;" onclick="HideNavSearch()">
<span style="font-size: 1.25rem;">取消</span>
</div>
</div>
<div id="sk-container" class="container hidden-sm hidden-xs">
<section class="main-content">
<div id="an-list">
<div id="an-list-nav">
<div class="sk-col my-rss">我的字幕组订阅更新</div>
<div class="sk-col my-rss-date js-episode-update active" data-predate="1" data-enddate="1" data-maximumitems="10">昨天至今</div>
<div class="sk-col my-rss-date js-episode-update" data-predate="0" data-enddate="1" data-maximumitems="10">今天</div>
<div class="sk-col my-rss-date js-episode-update" data-predate="1" data-enddate="0" data-maximumitems="10">昨天</div>
<div class="sk-col my-rss-date js-episode-update" data-predate="2" data-enddate="1" data-maximumitems="10">近三天</div>
<div class="sk-col my-rss-date js-episode-update" data-predate="-1" data-enddate="-1" data-maximumitems="10">OVA/剧场版 (beta)</div>
<div class="sk-col my-rss-date indent-btn active" onclick="ToggleEpisodeUpdates(this)"><i class="fa fa-angle-down fa-2x"></i></div>
</div>
<div id="an-episode-updates">
<div class="no-episode-update">
<img src="/images/mikan-pic.png" style="height:150px;" /><img src="/images/no-episode-update.png" style="height:60px;margin-left:30px;" />
</div>
</div>
</div>
<div style="margin-top: 10px; margin-bottom: -10px;">
<div style="width:100%; margin-right: auto; margin-left: auto;" class="hidden-xs hidden-sm">
<a href="https://shop119340084.taobao.com/?mm_sycmid=1_150417_dba461f2e2f73a9ea2a8fa11f33a1aee" onclick="ga('send', 'event', 'sswj_lg', 'clicked', 'ad');">
<img src="/images/SSWJ/sswj8_lg.jpg" style='height: 100%; width: 100%; object-fit: contain' />
</a>
</div>
<div style="width:100%; margin-right: auto; margin-left: auto;" class="hidden-lg hidden-md">
<a href="https://m.tb.cn/h.g0X5kru9wgYTRsp?mm_sycmid=1_150416_5914d148315f48d5297c751b84bac595" onclick="ga('send', 'event', 'sswj_sm', 'clicked', 'ad');">
<img src="/images/SSWJ/sswj8_sm.jpg" style='height: 100%; width: 100%; object-fit: contain' />
</a>
</div>
</div>
<div class="date-select-row row" style="padding-left:35px">
<ul class="navbar-nav date-select">
<li class="sk-col dropdown date-btn">
<div class="dropdown-toggle btn btn-default dropdown-custom" data-toggle="dropdown">
<div class="sk-col glyphicon glyphicon-calendar"></div>
<div class="sk-col date-text"> 2025 &#x51AC;&#x5B63;&#x756A;&#x7EC4; <span class="caret"></span> </div>
</div>
<ul class="dropdown-menu" role="menu">
<li class="dropdown-submenu">
<a class="default-cursor">2025</a>
<ul class="dropdown-menu">
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2025" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</a></li>
</ul>
</li>
<li class="dropdown-submenu">
<a class="default-cursor">2024</a>
<ul class="dropdown-menu">
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2024" data-season="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2024" data-season="&#x590F;">&#x590F;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2024" data-season="&#x6625;">&#x6625;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2024" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</a></li>
</ul>
</li>
<li class="dropdown-submenu">
<a class="default-cursor">2023</a>
<ul class="dropdown-menu">
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2023" data-season="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2023" data-season="&#x590F;">&#x590F;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2023" data-season="&#x6625;">&#x6625;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2023" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</a></li>
</ul>
</li>
<li class="dropdown-submenu">
<a class="default-cursor">2022</a>
<ul class="dropdown-menu">
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2022" data-season="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2022" data-season="&#x590F;">&#x590F;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2022" data-season="&#x6625;">&#x6625;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2022" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</a></li>
</ul>
</li>
<li class="dropdown-submenu">
<a class="default-cursor">2021</a>
<ul class="dropdown-menu">
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2021" data-season="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2021" data-season="&#x590F;">&#x590F;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2021" data-season="&#x6625;">&#x6625;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2021" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</a></li>
</ul>
</li>
<li class="dropdown-submenu">
<a class="default-cursor">2020</a>
<ul class="dropdown-menu">
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2020" data-season="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2020" data-season="&#x590F;">&#x590F;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2020" data-season="&#x6625;">&#x6625;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2020" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</a></li>
</ul>
</li>
<li class="dropdown-submenu">
<a class="default-cursor">2019</a>
<ul class="dropdown-menu">
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2019" data-season="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2019" data-season="&#x590F;">&#x590F;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2019" data-season="&#x6625;">&#x6625;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2019" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</a></li>
</ul>
</li>
<li class="dropdown-submenu">
<a class="default-cursor">2018</a>
<ul class="dropdown-menu">
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2018" data-season="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2018" data-season="&#x590F;">&#x590F;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2018" data-season="&#x6625;">&#x6625;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2018" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</a></li>
</ul>
</li>
<li class="dropdown-submenu">
<a class="default-cursor">2017</a>
<ul class="dropdown-menu">
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2017" data-season="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2017" data-season="&#x590F;">&#x590F;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2017" data-season="&#x6625;">&#x6625;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2017" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</a></li>
</ul>
</li>
<li class="dropdown-submenu">
<a class="default-cursor">2016</a>
<ul class="dropdown-menu">
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2016" data-season="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2016" data-season="&#x590F;">&#x590F;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2016" data-season="&#x6625;">&#x6625;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2016" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</a></li>
</ul>
</li>
<li class="dropdown-submenu">
<a class="default-cursor">2015</a>
<ul class="dropdown-menu">
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2015" data-season="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2015" data-season="&#x590F;">&#x590F;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2015" data-season="&#x6625;">&#x6625;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2015" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</a></li>
</ul>
</li>
<li class="dropdown-submenu">
<a class="default-cursor">2014</a>
<ul class="dropdown-menu">
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2014" data-season="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2014" data-season="&#x590F;">&#x590F;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2014" data-season="&#x6625;">&#x6625;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2014" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</a></li>
</ul>
</li>
<li class="dropdown-submenu">
<a class="default-cursor">2013</a>
<ul class="dropdown-menu">
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2013" data-season="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
</ul>
</li>
</ul>
</li>
</ul>
</div>
<div id="sk-body">
<svg width="0" height="0">
<defs>
<clipPath id="clip-triangle-1">
<polygon points="0 15,80 15,100 0,120 15,1120 15,1120 370,0 370"/>
</clipPath>
<clipPath id="clip-triangle-2">
<polygon points="0 15,310 15,330 0,350 15,1120 15,1120 370,0 370"/>
</clipPath>
<clipPath id="clip-triangle-3">
<polygon points="0 15,540 15,560 0,580 15,1120 15,1120 370,0 370"/>
</clipPath>
<clipPath id="clip-triangle-4">
<polygon points="0 15,770 15,790 0,810 15,1120 15,1120 370,0 370"/>
</clipPath>
<clipPath id="clip-triangle-5">
<polygon points="0 15,995 15,1015 0,1035 15,1120 15,1120 370,0 370"/>
</clipPath>
<linearGradient id="row-bg-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#e4dedf" />
<stop offset="100%" stop-color="#cbbcc4" />
</linearGradient>
</defs>
</svg>
<div class="sk-bangumi">
<div class="no-subscribe-bangumi"> >_< 您还没有订阅任何番组快去<a href="/">首页</a>添加订阅吧</div></div>
</div>
</section>
</div>
<div id="sk-mobile-container" class="m-home hidden-lg hidden-md">
<div style="height:16rem;">
<!-- Indicators -->
<div id="myCarousel" class="carousel slide carousel-fade" data-ride="carousel" style="padding-top:3.9rem;">
<!-- Wrapper for slides -->
<div class="carousel-inner" role="listbox">
<div class="item active carousel-bg" style="background-image:url('/images/Promotion/202501/2025_01_bangumi_3519.jpg?format=webp');" onclick="window.open('/Home/Bangumi/3519', '_self');">
</div>
<div class="item carousel-bg" style="background-image: url('/images/Promotion/202501/2025_01_bangumi_3530.jpg?format=webp');" onclick="window.open('/Home/Bangumi/3530', '_self');">
</div>
<div class="item carousel-bg" style="background-image: url('/images/Promotion/202501/2025_01_bangumi_3518.jpg?format=webp');" onclick="window.open('/Home/Bangumi/3518', '_self');">
</div>
<div class="item carousel-bg" style="background-image: url('/images/Promotion/202501/2025_01_bangumi_3539.jpg?format=webp');" onclick="window.open('/Home/Bangumi/3539', '_self');">
</div>
</div>
<div class="carousel-indicators" style="z-index:2;">
<img src="./../images/mikan-subscribe.png" style="border-radius: 5rem;width: 5rem;height: 5rem;margin-top: -2.5rem;">
</div>
</div>
</div>
<div class="m-home-subscribe">
<img src="./../images/mikan-subscribe.png" style="border-radius: 5rem;width: 5rem;height: 5rem;margin-top: -2.5rem;">
<div class="m-title">
<span class="title">我的订阅更新</span>
<div>
<div class="dropdown material-dropdown">
<button class="dropdown-toggle material-dropdown__btn" data-toggle="dropdown">
<span style="color: #3bc0c3;font-size: 1.25rem;" class="js-mobile-episode-update-date">昨天至今</span>
<span><i class="fa fa-angle-down" aria-hidden="true"></i></span>
</button>
<ul class="dropdown-menu material-dropdown-menu">
<li><a href="javascript:void(0);" class="material-dropdown-menu__link js-mobile-episode-update" data-predate="1" data-enddate="1" data-maximumitems="6">昨天至今</a></li>
<li><a href="javascript:void(0);" class="material-dropdown-menu__link js-mobile-episode-update" data-predate="0" data-enddate="1" data-maximumitems="6">今天</a></li>
<li><a href="javascript:void(0);" class="material-dropdown-menu__link js-mobile-episode-update" data-predate="1" data-enddate="0" data-maximumitems="6">昨天</a></li>
<li><a href="javascript:void(0);" class="material-dropdown-menu__link js-mobile-episode-update" data-predate="2" data-enddate="1" data-maximumitems="6">近三天</a></li>
<li><a href="javascript:void(0);" class="material-dropdown-menu__link js-mobile-episode-update" data-predate="6" data-enddate="1" data-maximumitems="6">最近一周</a></li>
</ul>
</div>
</div>
</div>
<div class="m-subscribe-list">
<div style="height: 3rem;color: #888;border: none;font-size: 1.25rem;">
暂无更新,添加更多订阅吧!
</div>
</div>
</div>
<div style="margin-top: 0px; margin-bottom: 10px;">
<div style="width:100%; margin-right: auto; margin-left: auto;" class="hidden-xs hidden-sm">
<a href="https://shop119340084.taobao.com/?mm_sycmid=1_150417_dba461f2e2f73a9ea2a8fa11f33a1aee" onclick="ga('send', 'event', 'sswj_lg', 'clicked', 'ad');">
<img src="/images/SSWJ/sswj7_lg.jpg" style='height: 100%; width: 100%; object-fit: contain' />
</a>
</div>
<div style="width:100%; margin-right: auto; margin-left: auto;" class="hidden-lg hidden-md">
<a href="https://m.tb.cn/h.g0X5kru9wgYTRsp?mm_sycmid=1_150416_5914d148315f48d5297c751b84bac595" onclick="ga('send', 'event', 'sswj_sm', 'clicked', 'ad');">
<img src="/images/SSWJ/sswj7_sm.jpg" style='height: 100%; width: 100%; object-fit: contain' />
</a>
</div>
</div>
<div class="m-home-week">
<div class="m-home-week-item" style="background-color:white;">
<div class="title">
<span class="monday">我的订阅</span>
</div>
<div class="detail">
</div>
</div>
</div>
</div>
<link rel="stylesheet" href="/css/bootstrap-toggle-round.css?v=ZHx5lTKQuvWMGeQuWNqkSQvuRt2u8x7w0URRg4MhfUo" />
<div class="modal modal-fullscreen fade" id="modal-nav" tabindex="-1" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true" style="background-color:#3bc0c3;">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-body" style="margin: auto;width:100%;">
<div class="m-tool">
<span class="m-close clickable"><i class="fa fa-times" aria-hidden="true" data-toggle="modal" data-target="#modal-nav"></i></span>
<div class="m-tool-toolbar">
<img src="/images/mikan-pic.png" style="width: 3rem;">
<img src="/images/mikan-text.png" style="width: 7rem;">
</div>
<div class="m-tool-list">
<ul>
<li><a href="/" class="link">主页</a></li>
<li class="m-tool-search-change"><a href="/Home/MyBangumi" class="link">订阅</a></li>
<li onclick="tool.clickSearch()" class="m-tool-search-change">
<i class="fa fa-search" aria-hidden="true"></i>&nbsp;&nbsp;搜索站内
</li>
<li class="m-tool-search-input">
<form method="get" action="/Home/Search">
<div style="display: flex;height: 100%;">
<input type="text" class="form-control" name="searchstr" style="font-size:16px;" />
<span style="width: 5rem;" onclick="tool.resetSearch()">取消</span>
</div>
</form>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal modal-fullscreen fade" id="modal-login" tabindex="-1" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true" style="background-color:#edf1f2;">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-body" style="margin: auto;width:100%;height:85vh;">
<div class="m-login">
<span class="m-left clickable"><i class="fa fa-angle-left" aria-hidden="true" data-toggle="modal" data-target="#modal-login"></i></span>
<div class="m-tool-title">
登陆mikan账号
</div>
<div style="text-align: center;margin-top: 2rem;">
<img src="/images/mikan-pic.png" style="width: 6rem;">
</div>
<form action="/Account/Login?ReturnUrl=%2FHome%2FMyBangumi" method="post"> <div>
<input type="text" class="form-control" aria-label="..." placeholder="用户名" name="UserName">
<input type="password" class="form-control" aria-label="..." placeholder="密码" name="Password">
</div>
<button class="form-control" type="submit">登录</button>
<input name="__RequestVerificationToken" type="hidden" value="CfDJ8MyNMqFNaC9JmJW13PvY-93GWOqu6SxfgR0S2fsWVSk41_SQEknnNNxKBZ6pMaRs2yMRKWarMeS2av33qkf82qO9KD9P_gm2ovTbvQyHwNtlRoBJBF1JSt_ZAdLZnvfTjfJ8q_Ih0ADcAt3lwJXdPxQ" /></form> <div class="m-goto-registry">
<a href="/Account/Register" class="w-other-c" style="color:#3bc0c3">立即注册</a>
</div>
</div>
</div>
</div>
</div>
</div>
<footer class="footer hidden-xs hidden-sm">
<div id="sk-footer" class="container text-center">
<div>Powered by Mikan Project <a href="/Home/Contact" target="_blank">联系我们</a></div>
<div>Cooperate by PlaymateCat@Lisa</div>
</div>
</footer>
<script>
var tool = {};
(function () {
var inputPEl = $('.m-tool-search-input');
var inputEl = inputPEl.find('input');
var changeEl = $('.m-tool-search-change');
inputPEl.hide();
tool.clickSearch = clickSearch;
tool.resetSearch = resetSearch;
function clickSearch() {
changeEl.hide();
inputPEl.show();
inputEl.focus();
}
function resetSearch() {
changeEl.show();
inputPEl.hide();
inputEl.val('');
}
})();
</script>
<script>
var pageUtil;
(function () {
pageUtil = {
isMobile: isMobile
};
function isMobile() {
var check = false;
(function (a) {
if (/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a) || /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0, 4))) check = true;
})(navigator.userAgent || navigator.vendor || window.opera);
return check;
}
})();
//detect if page is mobile
if (pageUtil.isMobile()) {
document.getElementsByTagName('html')[0].style['font-size'] = window.innerWidth / 32 + 'px';
}
</script>
</body>
<!-- here put your own javascript -->
<script src="/js/mikan.min.js?v=7USd_hfRE7KH46vQBdF29boa3ENWKMVFRTyD9a8XEDg"></script>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,13 @@
HOST="konobangu.com"
DATABASE_URL = "postgres://konobangu:konobangu@localhost:5432/konobangu"
STORAGE_DATA_DIR = "./data"
AUTH_TYPE = "basic" # or oidc AUTH_TYPE = "basic" # or oidc
BASIC_USER = "konobangu" BASIC_USER = "konobangu"
BASIC_PASSWORD = "konobangu" BASIC_PASSWORD = "konobangu"
# OIDC_ISSUER="https://auth.logto.io/oidc" # OIDC_ISSUER="https://auth.logto.io/oidc"
# OIDC_AUDIENCE = "https://konobangu.com/api" # OIDC_API_AUDIENCE = "https://konobangu.com/api"
# OIDC_CLIENT_ID = "client_id" # OIDC_CLIENT_ID = "client_id"
# OIDC_CLIENT_SECRET = "client_secret" # optional # OIDC_CLIENT_SECRET = "client_secret" # optional
# OIDC_EXTRA_SCOPES = "read:konobangu write:konobangu" # OIDC_EXTRA_SCOPES = "read:konobangu write:konobangu"
# OIDC_EXTRA_CLAIM_KEY = ""
# OIDC_EXTRA_CLAIM_VALUE = ""

View File

@ -1,20 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/app.css",
"baseColor": "neutral",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/presentation/utils",
"ui": "@/components/ui",
"lib": "@/presentation/lib",
"hooks": "@/presentation/hooks"
},
"iconLibrary": "lucide"
}

View File

@ -1,17 +0,0 @@
import type { CodegenConfig } from '@graphql-codegen/cli';
const config: CodegenConfig = {
schema: 'http://127.0.0.1:5001/api/graphql/introspection',
documents: ['src/**/*.{ts,tsx}'],
generates: {
'./src/infra/graphql/gql/': {
plugins: [],
preset: 'client',
presetConfig: {
gqlTagName: 'gql',
},
},
},
};
export default config;

View File

@ -4,20 +4,6 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script>
(function() {
try {
const theme = localStorage.getItem('prefers-color-scheme');
document.documentElement.classList.remove('dark', 'light');
if (theme === 'dark' || theme === 'light') {
document.documentElement.classList.add(theme);
} else {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.classList.add(systemTheme);
}
} catch(e) {}
})();
</script>
</head> </head>
<body> <body>

View File

@ -6,93 +6,53 @@
"scripts": { "scripts": {
"build": "rsbuild build", "build": "rsbuild build",
"dev": "rsbuild dev", "dev": "rsbuild dev",
"preview": "rsbuild preview", "preview": "rsbuild preview"
"codegen": "graphql-codegen --config graphql-codegen.ts",
"codegen-watch": "graphql-codegen --config graphql-codegen.ts --watch"
}, },
"dependencies": { "dependencies": {
"@abraham/reflection": "^0.13.0", "@abraham/reflection": "^0.12.0",
"@apollo/client": "^3.13.8", "@ark-ui/solid": "^4.10.2",
"@codemirror/language": "6.0.0", "@codemirror/language": "6.0.0",
"@corvu/drawer": "^0.2.3", "@corvu/drawer": "^0.2.3",
"@corvu/otp-field": "^0.1.4", "@corvu/otp-field": "^0.1.4",
"@corvu/resizable": "^0.2.4", "@corvu/resizable": "^0.2.4",
"@graphiql/toolkit": "^0.11.1", "@graphiql/toolkit": "^0.11.1",
"@hookform/resolvers": "^5.0.1", "@kobalte/core": "^0.13.9",
"@kobalte/tailwindcss": "^0.9.0",
"@outposts/injection-js": "^2.5.1", "@outposts/injection-js": "^2.5.1",
"@radix-ui/react-accordion": "^1.2.10", "@solid-primitives/graphql": "^2.2.0",
"@radix-ui/react-alert-dialog": "^1.1.13", "@solid-primitives/refs": "^1.1.0",
"@radix-ui/react-aspect-ratio": "^1.1.6", "@tailwindcss/postcss": "^4.0.9",
"@radix-ui/react-avatar": "^1.1.9",
"@radix-ui/react-checkbox": "^1.3.1",
"@radix-ui/react-collapsible": "^1.1.10",
"@radix-ui/react-context-menu": "^2.2.14",
"@radix-ui/react-dialog": "^1.1.13",
"@radix-ui/react-dropdown-menu": "^2.1.14",
"@radix-ui/react-hover-card": "^1.1.13",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-menubar": "^1.1.14",
"@radix-ui/react-navigation-menu": "^1.2.12",
"@radix-ui/react-popover": "^1.1.13",
"@radix-ui/react-progress": "^1.1.6",
"@radix-ui/react-radio-group": "^1.3.6",
"@radix-ui/react-scroll-area": "^1.2.8",
"@radix-ui/react-select": "^2.2.4",
"@radix-ui/react-separator": "^1.1.6",
"@radix-ui/react-slider": "^1.3.4",
"@radix-ui/react-slot": "^1.2.2",
"@radix-ui/react-switch": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.11",
"@radix-ui/react-toggle": "^1.1.8",
"@radix-ui/react-toggle-group": "^1.1.9",
"@radix-ui/react-tooltip": "^1.2.6",
"@rsbuild/plugin-react": "^1.2.0",
"@tanstack/react-query": "^5.75.6",
"@tanstack/react-router": "^1.112.13", "@tanstack/react-router": "^1.112.13",
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-devtools": "^1.112.13", "@tanstack/router-devtools": "^1.112.13",
"@tanstack/solid-router": "^1.112.12",
"arktype": "^2.1.6", "arktype": "^2.1.6",
"chart.js": "^4.4.8", "chart.js": "^4.4.8",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk-solid": "^1.1.2",
"date-fns": "^4.1.0", "embla-carousel-solid": "^8.5.2",
"embla-carousel-react": "^8.6.0", "graphiql": "^3.8.3",
"graphiql": "^4.0.2", "lucide-solid": "^0.477.0",
"graphql": "^16.11.0",
"input-otp": "^1.4.2",
"jotai": "^2.12.3",
"jotai-signal": "^0.9.0",
"lucide-react": "^0.509.0",
"oidc-client-rx": "0.1.0-alpha.9", "oidc-client-rx": "0.1.0-alpha.9",
"react": "^19.1.0", "react": "^18.3.1",
"react-day-picker": "9.6.0", "react-dom": "^18.3.1",
"react-dom": "^19.1.0",
"react-hook-form": "^7.56.3",
"react-resizable-panels": "^3.0.1",
"recharts": "^2.15.3",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
"sonner": "^2.0.3", "solid-js": "^1.9.5",
"tailwind-merge": "^3.2.0", "solid-sonner": "^0.2.8",
"tailwindcss": "^4.0.6", "tailwind-merge": "^3.0.2",
"tw-animate-css": "^1.2.7", "tailwindcss": "^3.4.17",
"type-fest": "^4.40.0", "tailwindcss-animate": "^1.0.7"
"vaul": "^1.1.2",
"zod": "^3.24.4"
}, },
"devDependencies": { "devDependencies": {
"@graphql-codegen/cli": "^5.0.6",
"@graphql-codegen/client-preset": "^4.8.1",
"@graphql-codegen/typescript": "^4.1.6",
"@graphql-typed-document-node/core": "^3.2.0",
"@parcel/watcher": "^2.5.1",
"@rsbuild/core": "^1.2.15", "@rsbuild/core": "^1.2.15",
"@rsbuild/plugin-babel": "^1.0.4",
"@rsbuild/plugin-solid": "^1.0.5",
"@tailwindcss/postcss": "^4.0.9", "@tailwindcss/postcss": "^4.0.9",
"@tanstack/react-router": "^1.112.0", "@tanstack/react-router": "^1.112.0",
"@tanstack/router-devtools": "^1.112.6", "@tanstack/router-devtools": "^1.112.6",
"@tanstack/router-plugin": "^1.112.13", "@tanstack/router-plugin": "^1.112.13",
"@types/react": "^19.1.2", "@types/react": "^18.3.18",
"@types/react-dom": "^19.1.2", "@types/react-dom": "^18.3.5",
"chalk": "^5.4.1", "chalk": "^5.4.1",
"commander": "^13.1.0", "commander": "^13.1.0",
"postcss": "^8.5.3" "postcss": "^8.5.3"

View File

@ -1,5 +1,5 @@
export default { export default {
plugins: { plugins: {
'@tailwindcss/postcss': {}, 'tailwindcss': {},
}, },
}; };

View File

@ -1,5 +1,6 @@
import { defineConfig } from '@rsbuild/core'; import { defineConfig } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react'; import { pluginBabel } from '@rsbuild/plugin-babel';
import { pluginSolid } from '@rsbuild/plugin-solid';
import { TanStackRouterRspack } from '@tanstack/router-plugin/rspack'; import { TanStackRouterRspack } from '@tanstack/router-plugin/rspack';
export default defineConfig({ export default defineConfig({
@ -7,11 +8,16 @@ export default defineConfig({
title: 'Konobangu', title: 'Konobangu',
favicon: './public/assets/favicon.ico', favicon: './public/assets/favicon.ico',
}, },
plugins: [pluginReact()], plugins: [
pluginBabel({
include: /\.(?:jsx|tsx)$/,
}),
pluginSolid(),
],
tools: { tools: {
rspack: { rspack: {
plugins: [ plugins: [
TanStackRouterRspack({ target: 'react', autoCodeSplitting: true }), TanStackRouterRspack({ target: 'solid', autoCodeSplitting: true }),
], ],
}, },
}, },

View File

@ -1,146 +1,149 @@
@import "tailwindcss"; @tailwind base;
@import "tw-animate-css"; @tailwind components;
@tailwind utilities;
@custom-variant dark (&:is(.dark *)); @layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
:root { --muted: 240 4.8% 95.9%;
--radius: 0.625rem; --muted-foreground: 240 3.8% 46.1%;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark { --popover: 0 0% 100%;
--background: oklch(0.145 0 0); --popover-foreground: 240 10% 3.9%;
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@theme inline { --border: 240 5.9% 90%;
--color-background: var(--background); --input: 240 5.9% 90%;
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
@keyframes accordion-down { --card: 0 0% 100%;
from { --card-foreground: 240 10% 3.9%;
height: 0;
} --primary: 240 5.9% 10%;
to { --primary-foreground: 0 0% 98%;
height: var(--radix-accordion-content-height);
} --secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--info: 204 94% 94%;
--info-foreground: 199 89% 48%;
--success: 149 80% 90%;
--success-foreground: 160 84% 39%;
--warning: 48 96% 89%;
--warning-foreground: 25 95% 53%;
--error: 0 93% 94%;
--error-foreground: 0 84% 60%;
--ring: 240 5.9% 10%;
--radius: 0.5rem;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
} }
@keyframes accordion-up { .dark,
from { [data-kb-theme="dark"] {
height: var(--radix-accordion-content-height); --background: 240 10% 3.9%;
} --foreground: 0 0% 98%;
to {
height: 0; --muted: 240 3.7% 15.9%;
} --muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--info: 204 94% 94%;
--info-foreground: 199 89% 48%;
--success: 149 80% 90%;
--success-foreground: 160 84% 39%;
--warning: 48 96% 89%;
--warning-foreground: 25 95% 53%;
--error: 0 93% 94%;
--error-foreground: 0 84% 60%;
--ring: 240 4.9% 83.9%;
--radius: 0.5rem;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
} }
/* custom start */
::-webkit-scrollbar {
width: 16px;
}
::-webkit-scrollbar-thumb {
border-radius: 9999px;
border: 4px solid transparent;
background-clip: content-box;
@apply bg-accent;
}
::-webkit-scrollbar-corner {
display: none;
}
/* custom end */
} }
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
font-feature-settings:
"rlig" 1,
"calt" 1;
} }
}
button:not(:disabled), @media (max-width: 640px) {
[role="button"]:not(:disabled) { .container {
cursor: pointer; @apply px-4;
} }
} }

View File

@ -1,79 +0,0 @@
import { AuthService } from '@/domains/auth/auth.service';
import { AUTH_PROVIDER, type AuthProvider } from '@/infra/auth/auth.provider';
import { BasicAuthProvider } from '@/infra/auth/basic';
import {
AUTH_METHOD,
type AuthMethodType,
getAppAuthMethod,
} from '@/infra/auth/defs';
import { OidcAuthProvider, buildOidcConfig } from '@/infra/auth/oidc';
import { UnreachableError } from '@/infra/errors/common';
import type { Injector, Provider } from '@outposts/injection-js';
import type { AnyRouter } from '@tanstack/react-router';
import {
type CheckAuthResultEventType,
provideAuth as provideOidcAuth,
withCheckAuthResultEvent,
withDefaultFeatures,
} from 'oidc-client-rx';
import { withTanstackRouter } from 'oidc-client-rx/adapters/@tanstack/react-router';
import type { Observable } from 'rxjs';
export function provideAuth(router: AnyRouter): Provider[] {
const providers: Provider[] = [AuthService];
const appAuthMethod = getAppAuthMethod();
if (appAuthMethod === AUTH_METHOD.OIDC) {
providers.push(
...provideOidcAuth(
{
config: buildOidcConfig(),
},
withDefaultFeatures({
router: { enabled: false },
securityStorage: { type: 'local-storage' },
}),
withTanstackRouter(router),
withCheckAuthResultEvent()
)
);
providers.push({
provide: AUTH_PROVIDER,
useClass: OidcAuthProvider,
});
} else if (appAuthMethod === AUTH_METHOD.BASIC) {
providers.push({
provide: AUTH_PROVIDER,
useClass: BasicAuthProvider,
});
} else {
throw new UnreachableError(`Unsupported auth method: ${appAuthMethod}`);
}
return providers;
}
export interface AuthContext {
type: AuthMethodType;
authService: AuthService;
authProvider: AuthProvider;
isAuthenticated$: Observable<boolean>;
userData$: Observable<{}>;
checkAuthResultEvent$: Observable<CheckAuthResultEventType>;
}
export function authContextFromInjector(injector: Injector): AuthContext {
const authService = injector.get(AuthService);
const authProvider = injector.get(AUTH_PROVIDER);
return {
type: authProvider.authMethod,
isAuthenticated$: authService.isAuthenticated$,
userData$: authService.authData$,
checkAuthResultEvent$: authService.checkAuthResultEvent$,
authService,
authProvider,
};
}
export function setupAuthContext(injector: Injector) {
const { authService } = authContextFromInjector(injector);
authService.setup();
}

View File

@ -1,19 +0,0 @@
import type { RouterContext } from '@/infra/routes/traits';
import { firstValueFrom } from 'rxjs';
import { authContextFromInjector } from './context';
export const beforeLoadGuard = async ({
context,
}: { context: RouterContext }) => {
const { isAuthenticated$, authProvider } = authContextFromInjector(
context.injector
);
if (!(await firstValueFrom(isAuthenticated$))) {
const isAuthenticated = await firstValueFrom(
authProvider.autoLoginPartialRoutesGuard()
);
if (!isAuthenticated) {
throw !isAuthenticated;
}
}
};

View File

@ -1,34 +0,0 @@
import { atomWithObservable } from 'jotai/utils';
import { useInjector } from 'oidc-client-rx/adapters/react';
import { useMemo } from 'react';
import type { Observable } from 'rxjs';
import { authContextFromInjector } from './context';
export function useAuth() {
const injector = useInjector();
const authContext = useMemo(
() => authContextFromInjector(injector),
[injector]
);
const isAuthenticated = useMemo(
() =>
atomWithObservable(
() => authContext.isAuthenticated$ as Observable<boolean>
),
[authContext.isAuthenticated$]
);
const authData = useMemo(
() => atomWithObservable(() => authContext.userData$ as Observable<any>),
[authContext]
);
return {
...authContext,
authData,
injector,
isAuthenticated,
};
}

View File

@ -1,5 +1,8 @@
import { LogLevel, type OpenIdConfiguration } from 'oidc-client-rx'; import { LogLevel, type OpenIdConfiguration } from 'oidc-client-rx';
export const isBasicAuth = process.env.AUTH_TYPE === 'basic';
export const isOidcAuth = process.env.AUTH_TYPE === 'oidc';
export function buildOidcConfig(): OpenIdConfiguration { export function buildOidcConfig(): OpenIdConfiguration {
const origin = window.location.origin; const origin = window.location.origin;
@ -18,7 +21,7 @@ export function buildOidcConfig(): OpenIdConfiguration {
responseType: 'code', responseType: 'code',
silentRenew: true, silentRenew: true,
useRefreshToken: true, useRefreshToken: true,
logLevel: LogLevel.None, logLevel: LogLevel.Debug,
autoUserInfo: !resource, autoUserInfo: !resource,
renewUserInfoAfterTokenRenew: !resource, renewUserInfoAfterTokenRenew: !resource,
customParamsAuthRequest: { customParamsAuthRequest: {

View File

@ -0,0 +1,4 @@
import { createSignal } from 'solid-js';
import { isBasicAuth } from './config';
export const [isAuthenticated, setIsAuthenticated] = createSignal(isBasicAuth);

Some files were not shown because too many files have changed in this diff Show More