mirror of
https://github.com/amtoaer/bili-sync.git
synced 2026-09-09 09:27:13 +08:00
feat: 大范围重构,支持视频合集下载 (#97)
This commit is contained in:
Generated
+13
-14
@@ -342,9 +342,9 @@ checksum = "fbb36e985947064623dbd357f727af08ffd077f93d696782f3c56365fa2e2799"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.79"
|
version = "0.1.80"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a507401cad91ec6a857ed5513a2073c82a9b9048762b885bb98655b306964681"
|
checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -423,6 +423,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
|
"async-trait",
|
||||||
"bili_sync_entity",
|
"bili_sync_entity",
|
||||||
"bili_sync_migration",
|
"bili_sync_migration",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -434,7 +435,6 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"handlebars",
|
"handlebars",
|
||||||
"hex",
|
"hex",
|
||||||
"log",
|
|
||||||
"memchr",
|
"memchr",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"prost",
|
"prost",
|
||||||
@@ -459,7 +459,6 @@ name = "bili_sync_entity"
|
|||||||
version = "2.0.3"
|
version = "2.0.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"sea-orm",
|
"sea-orm",
|
||||||
"serde",
|
|
||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -622,9 +621,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.5.7"
|
version = "4.5.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5db83dced34638ad474f39f250d7fea9598bdd239eaced1bdf45d597da0f433f"
|
checksum = "84b3edb18336f4df585bc9aa31dd99c036dfa5dc5e9a2939a722a188f3a8970d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap_builder",
|
"clap_builder",
|
||||||
"clap_derive",
|
"clap_derive",
|
||||||
@@ -632,9 +631,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_builder"
|
name = "clap_builder"
|
||||||
version = "4.5.7"
|
version = "4.5.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f7e204572485eb3fbf28f871612191521df159bc3e15a9f5064c66dba3a8c05f"
|
checksum = "c1c09dd5ada6c6c78075d6fd0da3f90d8080651e2d6cc8eb2f1aaa4034ced708"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstream",
|
"anstream",
|
||||||
"anstyle",
|
"anstyle",
|
||||||
@@ -644,9 +643,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_derive"
|
name = "clap_derive"
|
||||||
version = "4.5.5"
|
version = "4.5.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c780290ccf4fb26629baa7a1081e68ced113f1d3ec302fa5948f1c381ebf06c6"
|
checksum = "2bac35c6dafb060fd4d275d9a4ffae97917c13a6327903a8be2153cd964f7085"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -2119,9 +2118,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.34.0"
|
version = "0.35.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6f24d770aeca0eacb81ac29dfbc55ebcc09312fdd1f8bbecdc7e4a84e000e3b4"
|
checksum = "86e446ed58cef1bbfe847bc2fda0e2e4ea9f0e57b90c507d4781292590d72a4e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -2747,9 +2746,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_json"
|
name = "serde_json"
|
||||||
version = "1.0.118"
|
version = "1.0.120"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d947f6b3163d8857ea16c4fa0dd4840d52f3041039a85decd46867eb1abef2e4"
|
checksum = "4e0d21c9a8cae1235ad58a00c11cb40d4b1e5c784f1ef2c537876ed6ffd8b7c5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"itoa",
|
"itoa",
|
||||||
"ryu",
|
"ryu",
|
||||||
|
|||||||
+4
-4
@@ -19,8 +19,9 @@ anyhow = { version = "1.0.86", features = ["backtrace"] }
|
|||||||
arc-swap = { version = "1.7.1", features = ["serde"] }
|
arc-swap = { version = "1.7.1", features = ["serde"] }
|
||||||
async-std = { version = "1.12.0", features = ["attributes", "tokio1"] }
|
async-std = { version = "1.12.0", features = ["attributes", "tokio1"] }
|
||||||
async-stream = "0.3.5"
|
async-stream = "0.3.5"
|
||||||
|
async-trait = "0.1.80"
|
||||||
chrono = { version = "0.4.38", features = ["serde"] }
|
chrono = { version = "0.4.38", features = ["serde"] }
|
||||||
clap = { version = "4.5.7", features = ["env"] }
|
clap = { version = "4.5.8", features = ["env"] }
|
||||||
cookie = "0.18.1"
|
cookie = "0.18.1"
|
||||||
dirs = "5.0.1"
|
dirs = "5.0.1"
|
||||||
filenamify = "0.1.0"
|
filenamify = "0.1.0"
|
||||||
@@ -28,11 +29,10 @@ float-ord = "0.3.2"
|
|||||||
futures = "0.3.30"
|
futures = "0.3.30"
|
||||||
handlebars = "5.1.2"
|
handlebars = "5.1.2"
|
||||||
hex = "0.4.3"
|
hex = "0.4.3"
|
||||||
log = "0.4.22"
|
|
||||||
memchr = "2.7.4"
|
memchr = "2.7.4"
|
||||||
once_cell = "1.19.0"
|
once_cell = "1.19.0"
|
||||||
prost = "0.12.6"
|
prost = "0.12.6"
|
||||||
quick-xml = { version = "0.34.0", features = ["async-tokio"] }
|
quick-xml = { version = "0.35.0", features = ["async-tokio"] }
|
||||||
rand = "0.8.5"
|
rand = "0.8.5"
|
||||||
regex = "1.10.5"
|
regex = "1.10.5"
|
||||||
reqwest = { version = "0.12.5", features = [
|
reqwest = { version = "0.12.5", features = [
|
||||||
@@ -52,7 +52,7 @@ sea-orm = { version = "0.12.15", features = [
|
|||||||
] }
|
] }
|
||||||
sea-orm-migration = { version = "0.12.15", features = [] }
|
sea-orm-migration = { version = "0.12.15", features = [] }
|
||||||
serde = { version = "1.0.203", features = ["derive"] }
|
serde = { version = "1.0.203", features = ["derive"] }
|
||||||
serde_json = "1.0.118"
|
serde_json = "1.0.120"
|
||||||
strum = { version = "0.26.3", features = ["derive"] }
|
strum = { version = "0.26.3", features = ["derive"] }
|
||||||
thiserror = "1.0.61"
|
thiserror = "1.0.61"
|
||||||
tokio = { version = "1.38.0", features = ["full"] }
|
tokio = { version = "1.38.0", features = ["full"] }
|
||||||
|
|||||||
@@ -8,3 +8,14 @@ build-docker: build
|
|||||||
cp target/x86_64-unknown-linux-musl/release/bili-sync-rs ./Linux-x86_64-bili-sync-rs
|
cp target/x86_64-unknown-linux-musl/release/bili-sync-rs ./Linux-x86_64-bili-sync-rs
|
||||||
docker build . -t bili-sync-rs-local --build-arg="TARGETPLATFORM=linux/amd64"
|
docker build . -t bili-sync-rs-local --build-arg="TARGETPLATFORM=linux/amd64"
|
||||||
just clean
|
just clean
|
||||||
|
|
||||||
|
copy-config:
|
||||||
|
rm -rf ~/.config/bili-sync
|
||||||
|
cp -r ~/.config/nas/bili-sync-rs ~/.config/bili-sync
|
||||||
|
sed -i -e 's/\/Bilibilis/\/Test_Bilibilis/g' -e 's/.config\/nas/.config\/test_nas/g' ~/.config/bili-sync/config.toml
|
||||||
|
|
||||||
|
run:
|
||||||
|
cargo run
|
||||||
|
|
||||||
|
debug: copy-config
|
||||||
|
just run
|
||||||
@@ -12,6 +12,7 @@ readme = "../../README.md"
|
|||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
arc-swap = { workspace = true }
|
arc-swap = { workspace = true }
|
||||||
async-stream = { workspace = true }
|
async-stream = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
bili_sync_entity = { workspace = true }
|
bili_sync_entity = { workspace = true }
|
||||||
bili_sync_migration = { workspace = true }
|
bili_sync_migration = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
@@ -23,7 +24,6 @@ float-ord = { workspace = true }
|
|||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
handlebars = { workspace = true }
|
handlebars = { workspace = true }
|
||||||
hex = { workspace = true }
|
hex = { workspace = true }
|
||||||
log = { workspace = true }
|
|
||||||
memchr = { workspace = true }
|
memchr = { workspace = true }
|
||||||
once_cell = { workspace = true }
|
once_cell = { workspace = true }
|
||||||
prost = { workspace = true }
|
prost = { workspace = true }
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::pin::Pin;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use bili_sync_entity::*;
|
||||||
|
use bili_sync_migration::OnConflict;
|
||||||
|
use filenamify::filenamify;
|
||||||
|
use futures::Stream;
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use sea_orm::ActiveValue::Set;
|
||||||
|
use sea_orm::{DatabaseConnection, QuerySelect, TransactionTrait};
|
||||||
|
|
||||||
|
use super::VideoListModel;
|
||||||
|
use crate::bilibili::{BiliClient, BiliError, Collection, CollectionItem, CollectionType, Video, VideoInfo};
|
||||||
|
use crate::config::TEMPLATE;
|
||||||
|
use crate::utils::id_time_key;
|
||||||
|
use crate::utils::model::create_video_pages;
|
||||||
|
use crate::utils::status::Status;
|
||||||
|
|
||||||
|
pub async fn collection_from<'a>(
|
||||||
|
collection_item: &'a CollectionItem,
|
||||||
|
path: &Path,
|
||||||
|
bili_client: &'a BiliClient,
|
||||||
|
connection: &DatabaseConnection,
|
||||||
|
) -> Result<(Box<dyn VideoListModel>, Pin<Box<dyn Stream<Item = VideoInfo> + 'a>>)> {
|
||||||
|
let collection = Collection::new(bili_client, collection_item);
|
||||||
|
let collection_info = collection.get_info().await?;
|
||||||
|
collection::Entity::insert(collection::ActiveModel {
|
||||||
|
s_id: Set(collection_info.sid),
|
||||||
|
m_id: Set(collection_info.mid),
|
||||||
|
r#type: Set(collection_info.collection_type.into()),
|
||||||
|
name: Set(collection_info.name.clone()),
|
||||||
|
path: Set(path.to_string_lossy().to_string()),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.on_conflict(
|
||||||
|
OnConflict::columns([
|
||||||
|
collection::Column::SId,
|
||||||
|
collection::Column::MId,
|
||||||
|
collection::Column::Type,
|
||||||
|
])
|
||||||
|
.update_columns([collection::Column::Name, collection::Column::Path])
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.exec(connection)
|
||||||
|
.await?;
|
||||||
|
Ok((
|
||||||
|
Box::new(
|
||||||
|
collection::Entity::find()
|
||||||
|
.filter(
|
||||||
|
collection::Column::SId
|
||||||
|
.eq(collection_item.sid.clone())
|
||||||
|
.and(collection::Column::MId.eq(collection_item.mid.clone()))
|
||||||
|
.and(collection::Column::Type.eq(Into::<i32>::into(collection_item.collection_type.clone()))),
|
||||||
|
)
|
||||||
|
.one(connection)
|
||||||
|
.await?
|
||||||
|
.unwrap(),
|
||||||
|
),
|
||||||
|
Box::pin(collection.into_simple_video_stream()),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl VideoListModel for collection::Model {
|
||||||
|
async fn video_count(&self, connection: &DatabaseConnection) -> Result<u64> {
|
||||||
|
Ok(video::Entity::find()
|
||||||
|
.filter(video::Column::CollectionId.eq(self.id))
|
||||||
|
.count(connection)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn unfilled_videos(&self, connection: &DatabaseConnection) -> Result<Vec<video::Model>> {
|
||||||
|
Ok(video::Entity::find()
|
||||||
|
.filter(
|
||||||
|
video::Column::CollectionId
|
||||||
|
.eq(self.id)
|
||||||
|
.and(video::Column::Valid.eq(true))
|
||||||
|
.and(video::Column::DownloadStatus.eq(0))
|
||||||
|
.and(video::Column::Category.eq(2))
|
||||||
|
.and(video::Column::SinglePage.is_null()),
|
||||||
|
)
|
||||||
|
.all(connection)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn unhandled_video_pages(
|
||||||
|
&self,
|
||||||
|
connection: &DatabaseConnection,
|
||||||
|
) -> Result<Vec<(video::Model, Vec<page::Model>)>> {
|
||||||
|
Ok(video::Entity::find()
|
||||||
|
.filter(
|
||||||
|
video::Column::CollectionId
|
||||||
|
.eq(self.id)
|
||||||
|
.and(video::Column::Valid.eq(true))
|
||||||
|
.and(video::Column::DownloadStatus.lt(Status::handled()))
|
||||||
|
.and(video::Column::Category.eq(2))
|
||||||
|
.and(video::Column::SinglePage.is_not_null()),
|
||||||
|
)
|
||||||
|
.find_with_related(page::Entity)
|
||||||
|
.all(connection)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exist_labels(
|
||||||
|
&self,
|
||||||
|
videos_info: &[VideoInfo],
|
||||||
|
connection: &DatabaseConnection,
|
||||||
|
) -> Result<HashSet<String>> {
|
||||||
|
let bvids = videos_info.iter().map(|v| v.bvid().to_string()).collect::<Vec<_>>();
|
||||||
|
Ok(video::Entity::find()
|
||||||
|
.filter(
|
||||||
|
video::Column::CollectionId
|
||||||
|
.eq(self.id)
|
||||||
|
.and(video::Column::Bvid.is_in(bvids)),
|
||||||
|
)
|
||||||
|
.select_only()
|
||||||
|
.columns([video::Column::Bvid, video::Column::Favtime])
|
||||||
|
.into_tuple()
|
||||||
|
.all(connection)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|(bvid, time)| id_time_key(&bvid, &time))
|
||||||
|
.collect::<HashSet<_>>())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn video_model_by_info(&self, video_info: &VideoInfo, base_model: Option<video::Model>) -> video::ActiveModel {
|
||||||
|
let mut video_model = video_info.to_model(base_model);
|
||||||
|
video_model.collection_id = Set(Some(self.id));
|
||||||
|
if let Some(fmt_args) = &video_info.to_fmt_args() {
|
||||||
|
video_model.path = Set(Path::new(&self.path)
|
||||||
|
.join(filenamify(
|
||||||
|
TEMPLATE
|
||||||
|
.render("video", fmt_args)
|
||||||
|
.unwrap_or_else(|_| video_info.bvid().to_string()),
|
||||||
|
))
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string());
|
||||||
|
}
|
||||||
|
video_model
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_videos_detail(
|
||||||
|
&self,
|
||||||
|
bili_clent: &BiliClient,
|
||||||
|
videos_model: Vec<video::Model>,
|
||||||
|
connection: &DatabaseConnection,
|
||||||
|
) -> Result<()> {
|
||||||
|
for video_model in videos_model {
|
||||||
|
let video = Video::new(bili_clent, video_model.bvid.clone());
|
||||||
|
let info: Result<_> = async { Ok((video.get_tags().await?, video.get_view_info().await?)) }.await;
|
||||||
|
match info {
|
||||||
|
Ok((tags, view_info)) => {
|
||||||
|
let VideoInfo::View { pages, .. } = &view_info else {
|
||||||
|
unreachable!("view_info must be VideoInfo::View")
|
||||||
|
};
|
||||||
|
let txn = connection.begin().await?;
|
||||||
|
// 将分页信息写入数据库
|
||||||
|
create_video_pages(pages, &video_model, &txn).await?;
|
||||||
|
// 将页标记和 tag 写入数据库
|
||||||
|
let mut video_active_model = self.video_model_by_info(&view_info, Some(video_model));
|
||||||
|
video_active_model.single_page = Set(Some(pages.len() == 1));
|
||||||
|
video_active_model.tags = Set(Some(serde_json::to_value(tags).unwrap()));
|
||||||
|
video_active_model.save(&txn).await?;
|
||||||
|
txn.commit().await?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!(
|
||||||
|
"获取视频 {} - {} 的详细信息失败,错误为:{}",
|
||||||
|
&video_model.bvid, &video_model.name, e
|
||||||
|
);
|
||||||
|
if let Some(BiliError::RequestFailed(-404, _)) = e.downcast_ref::<BiliError>() {
|
||||||
|
let mut video_active_model: video::ActiveModel = video_model.into();
|
||||||
|
video_active_model.valid = Set(false);
|
||||||
|
video_active_model.save(connection).await?;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_fetch_video_start(&self) {
|
||||||
|
info!(
|
||||||
|
"开始获取{} {} - {} 的视频与分页信息...",
|
||||||
|
CollectionType::from(self.r#type),
|
||||||
|
self.s_id,
|
||||||
|
self.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_fetch_video_end(&self) {
|
||||||
|
info!(
|
||||||
|
"获取{} {} - {} 的视频与分页信息完成",
|
||||||
|
CollectionType::from(self.r#type),
|
||||||
|
self.s_id,
|
||||||
|
self.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_download_video_start(&self) {
|
||||||
|
info!(
|
||||||
|
"开始下载{}: {} - {} 中所有未处理过的视频...",
|
||||||
|
CollectionType::from(self.r#type),
|
||||||
|
self.s_id,
|
||||||
|
self.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_download_video_end(&self) {
|
||||||
|
info!(
|
||||||
|
"下载{}: {} - {} 中未处理过的视频完成",
|
||||||
|
CollectionType::from(self.r#type),
|
||||||
|
self.s_id,
|
||||||
|
self.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_refresh_video_start(&self) {
|
||||||
|
info!(
|
||||||
|
"开始扫描{}: {} - {} 的新视频...",
|
||||||
|
CollectionType::from(self.r#type),
|
||||||
|
self.s_id,
|
||||||
|
self.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_refresh_video_end(&self, got_count: usize, new_count: u64) {
|
||||||
|
info!(
|
||||||
|
"扫描{}: {} - {} 的新视频完成,获取了 {} 条新视频,其中有 {} 条新视频",
|
||||||
|
CollectionType::from(self.r#type),
|
||||||
|
self.s_id,
|
||||||
|
self.name,
|
||||||
|
got_count,
|
||||||
|
new_count,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::pin::Pin;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use bili_sync_entity::*;
|
||||||
|
use bili_sync_migration::OnConflict;
|
||||||
|
use filenamify::filenamify;
|
||||||
|
use futures::Stream;
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use sea_orm::ActiveValue::Set;
|
||||||
|
use sea_orm::{DatabaseConnection, QuerySelect, TransactionTrait};
|
||||||
|
|
||||||
|
use super::VideoListModel;
|
||||||
|
use crate::bilibili::{BiliClient, BiliError, FavoriteList, Video, VideoInfo};
|
||||||
|
use crate::config::TEMPLATE;
|
||||||
|
use crate::utils::id_time_key;
|
||||||
|
use crate::utils::model::create_video_pages;
|
||||||
|
use crate::utils::status::Status;
|
||||||
|
|
||||||
|
pub async fn favorite_from<'a>(
|
||||||
|
fid: &str,
|
||||||
|
path: &Path,
|
||||||
|
bili_client: &'a BiliClient,
|
||||||
|
connection: &DatabaseConnection,
|
||||||
|
) -> Result<(Box<dyn VideoListModel>, Pin<Box<dyn Stream<Item = VideoInfo> + 'a>>)> {
|
||||||
|
let favorite = FavoriteList::new(bili_client, fid.to_owned());
|
||||||
|
let favorite_info = favorite.get_info().await?;
|
||||||
|
favorite::Entity::insert(favorite::ActiveModel {
|
||||||
|
f_id: Set(favorite_info.id),
|
||||||
|
name: Set(favorite_info.title.clone()),
|
||||||
|
path: Set(path.to_string_lossy().to_string()),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.on_conflict(
|
||||||
|
OnConflict::column(favorite::Column::FId)
|
||||||
|
.update_columns([favorite::Column::Name, favorite::Column::Path])
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.exec(connection)
|
||||||
|
.await?;
|
||||||
|
Ok((
|
||||||
|
Box::new(
|
||||||
|
favorite::Entity::find()
|
||||||
|
.filter(favorite::Column::FId.eq(favorite_info.id))
|
||||||
|
.one(connection)
|
||||||
|
.await?
|
||||||
|
.unwrap(),
|
||||||
|
),
|
||||||
|
Box::pin(favorite.into_video_stream()),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl VideoListModel for favorite::Model {
|
||||||
|
async fn video_count(&self, connection: &DatabaseConnection) -> Result<u64> {
|
||||||
|
Ok(video::Entity::find()
|
||||||
|
.filter(video::Column::FavoriteId.eq(self.id))
|
||||||
|
.count(connection)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn unfilled_videos(&self, connection: &DatabaseConnection) -> Result<Vec<video::Model>> {
|
||||||
|
Ok(video::Entity::find()
|
||||||
|
.filter(
|
||||||
|
video::Column::FavoriteId
|
||||||
|
.eq(self.id)
|
||||||
|
.and(video::Column::Valid.eq(true))
|
||||||
|
.and(video::Column::DownloadStatus.eq(0))
|
||||||
|
.and(video::Column::Category.eq(2))
|
||||||
|
.and(video::Column::SinglePage.is_null()),
|
||||||
|
)
|
||||||
|
.all(connection)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn unhandled_video_pages(
|
||||||
|
&self,
|
||||||
|
connection: &DatabaseConnection,
|
||||||
|
) -> Result<Vec<(video::Model, Vec<page::Model>)>> {
|
||||||
|
Ok(video::Entity::find()
|
||||||
|
.filter(
|
||||||
|
video::Column::FavoriteId
|
||||||
|
.eq(self.id)
|
||||||
|
.and(video::Column::Valid.eq(true))
|
||||||
|
.and(video::Column::DownloadStatus.lt(Status::handled()))
|
||||||
|
.and(video::Column::Category.eq(2))
|
||||||
|
.and(video::Column::SinglePage.is_not_null()),
|
||||||
|
)
|
||||||
|
.find_with_related(page::Entity)
|
||||||
|
.all(connection)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exist_labels(
|
||||||
|
&self,
|
||||||
|
videos_info: &[VideoInfo],
|
||||||
|
connection: &DatabaseConnection,
|
||||||
|
) -> Result<HashSet<String>> {
|
||||||
|
let bvids = videos_info.iter().map(|v| v.bvid().to_string()).collect::<Vec<_>>();
|
||||||
|
Ok(video::Entity::find()
|
||||||
|
.filter(
|
||||||
|
video::Column::FavoriteId
|
||||||
|
.eq(self.id)
|
||||||
|
.and(video::Column::Bvid.is_in(bvids)),
|
||||||
|
)
|
||||||
|
.select_only()
|
||||||
|
.columns([video::Column::Bvid, video::Column::Favtime])
|
||||||
|
.into_tuple()
|
||||||
|
.all(connection)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|(bvid, time)| id_time_key(&bvid, &time))
|
||||||
|
.collect::<HashSet<_>>())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn video_model_by_info(&self, video_info: &VideoInfo, base_model: Option<video::Model>) -> video::ActiveModel {
|
||||||
|
let mut video_model = video_info.to_model(base_model);
|
||||||
|
video_model.favorite_id = Set(Some(self.id));
|
||||||
|
if let Some(fmt_args) = &video_info.to_fmt_args() {
|
||||||
|
video_model.path = Set(Path::new(&self.path)
|
||||||
|
.join(filenamify(
|
||||||
|
TEMPLATE
|
||||||
|
.render("video", fmt_args)
|
||||||
|
.unwrap_or_else(|_| video_info.bvid().to_string()),
|
||||||
|
))
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string());
|
||||||
|
}
|
||||||
|
video_model
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_videos_detail(
|
||||||
|
&self,
|
||||||
|
bili_clent: &BiliClient,
|
||||||
|
videos_model: Vec<video::Model>,
|
||||||
|
connection: &DatabaseConnection,
|
||||||
|
) -> Result<()> {
|
||||||
|
for video_model in videos_model {
|
||||||
|
let video = Video::new(bili_clent, video_model.bvid.clone());
|
||||||
|
let info: Result<_> = async { Ok((video.get_tags().await?, video.get_pages().await?)) }.await;
|
||||||
|
match info {
|
||||||
|
Ok((tags, pages_info)) => {
|
||||||
|
let txn = connection.begin().await?;
|
||||||
|
// 将分页信息写入数据库
|
||||||
|
create_video_pages(&pages_info, &video_model, &txn).await?;
|
||||||
|
// 将页标记和 tag 写入数据库
|
||||||
|
let mut video_active_model: video::ActiveModel = video_model.into();
|
||||||
|
video_active_model.single_page = Set(Some(pages_info.len() == 1));
|
||||||
|
video_active_model.tags = Set(Some(serde_json::to_value(tags).unwrap()));
|
||||||
|
video_active_model.save(&txn).await?;
|
||||||
|
txn.commit().await?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!(
|
||||||
|
"获取视频 {} - {} 的详细信息失败,错误为:{}",
|
||||||
|
&video_model.bvid, &video_model.name, e
|
||||||
|
);
|
||||||
|
if let Some(BiliError::RequestFailed(-404, _)) = e.downcast_ref::<BiliError>() {
|
||||||
|
let mut video_active_model: video::ActiveModel = video_model.into();
|
||||||
|
video_active_model.valid = Set(false);
|
||||||
|
video_active_model.save(connection).await?;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_fetch_video_start(&self) {
|
||||||
|
info!("开始获取收藏夹 {} - {} 的视频与分页信息...", self.f_id, self.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_fetch_video_end(&self) {
|
||||||
|
info!("获取收藏夹 {} - {} 的视频与分页信息完成", self.f_id, self.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_download_video_start(&self) {
|
||||||
|
info!("开始下载收藏夹: {} - {} 中所有未处理过的视频...", self.f_id, self.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_download_video_end(&self) {
|
||||||
|
info!("下载收藏夹: {} - {} 中未处理过的视频完成", self.f_id, self.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_refresh_video_start(&self) {
|
||||||
|
info!("开始扫描收藏夹: {} - {} 的新视频...", self.f_id, self.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_refresh_video_end(&self, got_count: usize, new_count: u64) {
|
||||||
|
info!(
|
||||||
|
"扫描收藏夹: {} - {} 的新视频完成,获取了 {} 条新视频,其中有 {} 条新视频",
|
||||||
|
self.f_id, self.name, got_count, new_count
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
mod collection;
|
||||||
|
mod favorite;
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::pin::Pin;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use bili_sync_migration::IntoIden;
|
||||||
|
pub use collection::collection_from;
|
||||||
|
pub use favorite::favorite_from;
|
||||||
|
use futures::Stream;
|
||||||
|
use sea_orm::DatabaseConnection;
|
||||||
|
|
||||||
|
use crate::bilibili::{BiliClient, CollectionItem, VideoInfo};
|
||||||
|
|
||||||
|
pub enum Args<'a> {
|
||||||
|
Favorite { fid: &'a str },
|
||||||
|
Collection { collection_item: &'a CollectionItem },
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn video_list_from<'a>(
|
||||||
|
args: Args<'a>,
|
||||||
|
path: &Path,
|
||||||
|
bili_client: &'a BiliClient,
|
||||||
|
connection: &DatabaseConnection,
|
||||||
|
) -> Result<(Box<dyn VideoListModel>, Pin<Box<dyn Stream<Item = VideoInfo> + 'a>>)> {
|
||||||
|
match args {
|
||||||
|
Args::Favorite { fid } => favorite_from(fid, path, bili_client, connection).await,
|
||||||
|
Args::Collection { collection_item } => collection_from(collection_item, path, bili_client, connection).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn unique_video_columns() -> impl IntoIterator<Item = impl IntoIden> {
|
||||||
|
[
|
||||||
|
bili_sync_entity::video::Column::CollectionId,
|
||||||
|
bili_sync_entity::video::Column::FavoriteId,
|
||||||
|
bili_sync_entity::video::Column::Bvid,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait VideoListModel {
|
||||||
|
/* 逻辑相关 */
|
||||||
|
|
||||||
|
async fn video_count(&self, connection: &DatabaseConnection) -> Result<u64>;
|
||||||
|
|
||||||
|
/// 获取未填充的视频
|
||||||
|
async fn unfilled_videos(&self, connection: &DatabaseConnection) -> Result<Vec<bili_sync_entity::video::Model>>;
|
||||||
|
|
||||||
|
/// 获取未处理的视频和分页
|
||||||
|
async fn unhandled_video_pages(
|
||||||
|
&self,
|
||||||
|
connection: &DatabaseConnection,
|
||||||
|
) -> Result<Vec<(bili_sync_entity::video::Model, Vec<bili_sync_entity::page::Model>)>>;
|
||||||
|
|
||||||
|
/// 获取该批次视频的存在标记
|
||||||
|
async fn exist_labels(&self, videos_info: &[VideoInfo], connection: &DatabaseConnection)
|
||||||
|
-> Result<HashSet<String>>;
|
||||||
|
|
||||||
|
/// 获取视频信息对应的视频 model
|
||||||
|
fn video_model_by_info(
|
||||||
|
&self,
|
||||||
|
video_info: &VideoInfo,
|
||||||
|
base_model: Option<bili_sync_entity::video::Model>,
|
||||||
|
) -> bili_sync_entity::video::ActiveModel;
|
||||||
|
|
||||||
|
/// 获取视频 model 中缺失的信息
|
||||||
|
async fn fetch_videos_detail(
|
||||||
|
&self,
|
||||||
|
bili_client: &BiliClient,
|
||||||
|
videos_model: Vec<bili_sync_entity::video::Model>,
|
||||||
|
connection: &DatabaseConnection,
|
||||||
|
) -> Result<()>;
|
||||||
|
|
||||||
|
/* 日志相关 */
|
||||||
|
fn log_fetch_video_start(&self);
|
||||||
|
|
||||||
|
fn log_fetch_video_end(&self);
|
||||||
|
|
||||||
|
fn log_download_video_start(&self);
|
||||||
|
|
||||||
|
fn log_download_video_end(&self);
|
||||||
|
|
||||||
|
fn log_refresh_video_start(&self);
|
||||||
|
|
||||||
|
fn log_refresh_video_end(&self, got_count: usize, new_count: u64);
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
use std::fmt::{Display, Formatter};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use async_stream::stream;
|
||||||
|
use futures::Stream;
|
||||||
|
use reqwest::Method;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::bilibili::{BiliClient, Validate, VideoInfo};
|
||||||
|
|
||||||
|
#[derive(PartialEq, Eq, Hash, Clone, Debug)]
|
||||||
|
pub enum CollectionType {
|
||||||
|
Series,
|
||||||
|
Season,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CollectionType> for i32 {
|
||||||
|
fn from(v: CollectionType) -> Self {
|
||||||
|
match v {
|
||||||
|
CollectionType::Series => 1,
|
||||||
|
CollectionType::Season => 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<i32> for CollectionType {
|
||||||
|
fn from(v: i32) -> Self {
|
||||||
|
match v {
|
||||||
|
1 => CollectionType::Series,
|
||||||
|
2 => CollectionType::Season,
|
||||||
|
_ => panic!("invalid collection type"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for CollectionType {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
|
let s = match self {
|
||||||
|
CollectionType::Series => "视频列表",
|
||||||
|
CollectionType::Season => "视频合集",
|
||||||
|
};
|
||||||
|
write!(f, "{}", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(PartialEq, Eq, Hash, Debug)]
|
||||||
|
pub struct CollectionItem {
|
||||||
|
pub mid: String,
|
||||||
|
pub sid: String,
|
||||||
|
pub collection_type: CollectionType,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Collection<'a> {
|
||||||
|
client: &'a BiliClient,
|
||||||
|
collection: &'a CollectionItem,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
pub struct CollectionInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub mid: i64,
|
||||||
|
pub sid: i64,
|
||||||
|
pub collection_type: CollectionType,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for CollectionInfo {
|
||||||
|
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CollectionInfoRaw {
|
||||||
|
mid: i64,
|
||||||
|
name: String,
|
||||||
|
season_id: Option<i64>,
|
||||||
|
series_id: Option<i64>,
|
||||||
|
}
|
||||||
|
let raw = CollectionInfoRaw::deserialize(deserializer)?;
|
||||||
|
let (sid, collection_type) = match (raw.season_id, raw.series_id) {
|
||||||
|
(Some(sid), None) => (sid, CollectionType::Season),
|
||||||
|
(None, Some(sid)) => (sid, CollectionType::Series),
|
||||||
|
_ => return Err(serde::de::Error::custom("invalid collection info")),
|
||||||
|
};
|
||||||
|
Ok(CollectionInfo {
|
||||||
|
mid: raw.mid,
|
||||||
|
name: raw.name,
|
||||||
|
sid,
|
||||||
|
collection_type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Collection<'a> {
|
||||||
|
pub fn new(client: &'a BiliClient, collection: &'a CollectionItem) -> Self {
|
||||||
|
Self { client, collection }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_info(&self) -> Result<CollectionInfo> {
|
||||||
|
let meta = match self.collection.collection_type {
|
||||||
|
// 没有找到专门获取 Season 信息的接口,所以直接获取第一页,从里面取 meta 信息
|
||||||
|
CollectionType::Season => self.get_videos(1).await?["data"]["meta"].take(),
|
||||||
|
CollectionType::Series => self.get_series_info().await?["data"]["meta"].take(),
|
||||||
|
};
|
||||||
|
Ok(serde_json::from_value(meta)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_series_info(&self) -> Result<Value> {
|
||||||
|
assert!(
|
||||||
|
self.collection.collection_type == CollectionType::Series,
|
||||||
|
"collection type is not series"
|
||||||
|
);
|
||||||
|
self.client
|
||||||
|
.request(Method::GET, "https://api.bilibili.com/x/series/series")
|
||||||
|
.query(&[("series_id", self.collection.sid.as_str())])
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json::<Value>()
|
||||||
|
.await?
|
||||||
|
.validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_videos(&self, page: i32) -> Result<Value> {
|
||||||
|
let page = page.to_string();
|
||||||
|
let (url, query) = match self.collection.collection_type {
|
||||||
|
CollectionType::Series => (
|
||||||
|
"https://api.bilibili.com/x/series/archives",
|
||||||
|
vec![
|
||||||
|
("mid", self.collection.mid.as_str()),
|
||||||
|
("series_id", self.collection.sid.as_str()),
|
||||||
|
("only_normal", "true"),
|
||||||
|
("sort", "desc"),
|
||||||
|
("pn", page.as_str()),
|
||||||
|
("ps", "30"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
CollectionType::Season => (
|
||||||
|
"https://api.bilibili.com/x/polymer/web-space/seasons_archives_list",
|
||||||
|
vec![
|
||||||
|
("mid", self.collection.mid.as_str()),
|
||||||
|
("season_id", self.collection.sid.as_str()),
|
||||||
|
("sort_reverse", "true"),
|
||||||
|
("page_num", page.as_str()),
|
||||||
|
("page_size", "30"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
self.client
|
||||||
|
.request(Method::GET, url)
|
||||||
|
.query(&query)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json::<Value>()
|
||||||
|
.await?
|
||||||
|
.validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_simple_video_stream(self) -> impl Stream<Item = VideoInfo> + 'a {
|
||||||
|
stream! {
|
||||||
|
let mut page = 1;
|
||||||
|
loop {
|
||||||
|
let mut videos = match self.get_videos(page).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
error!("failed to get videos of collection {:?} page {}: {}", self.collection, page, e);
|
||||||
|
break;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if !videos["data"]["archives"].is_array() {
|
||||||
|
warn!("no videos found in collection {:?} page {}", self.collection, page);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let videos_info = match serde_json::from_value::<Vec<VideoInfo>>(videos["data"]["archives"].take()) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
error!("failed to parse videos of collection {:?} page {}: {}", self.collection, page, e);
|
||||||
|
break;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
for video_info in videos_info.into_iter(){
|
||||||
|
yield video_info;
|
||||||
|
}
|
||||||
|
let fields = match self.collection.collection_type{
|
||||||
|
CollectionType::Series => ["num", "size", "total"],
|
||||||
|
CollectionType::Season => ["page_num", "page_size", "total"],
|
||||||
|
};
|
||||||
|
let fields = fields.into_iter().map(|f| videos["data"]["page"][f].as_i64()).collect::<Option<Vec<i64>>>().map(|v| (v[0], v[1], v[2]));
|
||||||
|
let Some((num, size, total)) = fields else {
|
||||||
|
error!("failed to get pages of collection {:?} page {}: {:?}", self.collection, page, fields);
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if num * size >= total {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_collection_info_parse() {
|
||||||
|
let testcases = vec![
|
||||||
|
(
|
||||||
|
r#"
|
||||||
|
{
|
||||||
|
"category": 0,
|
||||||
|
"cover": "https://archive.biliimg.com/bfs/archive/a6fbf7a7b9f4af09d9cf40482268634df387ef68.jpg",
|
||||||
|
"description": "",
|
||||||
|
"mid": 521722088,
|
||||||
|
"name": "合集·【命运方舟全剧情解说】",
|
||||||
|
"ptime": 1714701600,
|
||||||
|
"season_id": 1987140,
|
||||||
|
"total": 10
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
CollectionInfo {
|
||||||
|
mid: 521722088,
|
||||||
|
name: "合集·【命运方舟全剧情解说】".to_owned(),
|
||||||
|
sid: 1987140,
|
||||||
|
collection_type: CollectionType::Season,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r#"
|
||||||
|
{
|
||||||
|
"series_id": 387212,
|
||||||
|
"mid": 521722088,
|
||||||
|
"name": "提瓦特冒险记",
|
||||||
|
"description": "原神沙雕般的游戏体验",
|
||||||
|
"keywords": [
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"creator": "",
|
||||||
|
"state": 2,
|
||||||
|
"last_update_ts": 1633167320,
|
||||||
|
"total": 3,
|
||||||
|
"ctime": 1633167320,
|
||||||
|
"mtime": 1633167320,
|
||||||
|
"raw_keywords": "",
|
||||||
|
"category": 1
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
CollectionInfo {
|
||||||
|
mid: 521722088,
|
||||||
|
name: "提瓦特冒险记".to_owned(),
|
||||||
|
sid: 387212,
|
||||||
|
collection_type: CollectionType::Series,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (json, expect) in testcases {
|
||||||
|
let info: CollectionInfo = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(info, expect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use async_stream::stream;
|
use async_stream::stream;
|
||||||
use chrono::serde::ts_seconds;
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use futures::Stream;
|
use futures::Stream;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::bilibili::{BiliClient, Validate};
|
use crate::bilibili::{BiliClient, Validate, VideoInfo};
|
||||||
pub struct FavoriteList<'a> {
|
pub struct FavoriteList<'a> {
|
||||||
client: &'a BiliClient,
|
client: &'a BiliClient,
|
||||||
fid: String,
|
fid: String,
|
||||||
@@ -17,24 +15,6 @@ pub struct FavoriteListInfo {
|
|||||||
pub title: String,
|
pub title: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
|
||||||
pub struct VideoInfo {
|
|
||||||
pub title: String,
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub vtype: i32,
|
|
||||||
pub bvid: String,
|
|
||||||
pub intro: String,
|
|
||||||
pub cover: String,
|
|
||||||
pub upper: Upper,
|
|
||||||
#[serde(with = "ts_seconds")]
|
|
||||||
pub ctime: DateTime<Utc>,
|
|
||||||
#[serde(with = "ts_seconds")]
|
|
||||||
pub fav_time: DateTime<Utc>,
|
|
||||||
#[serde(with = "ts_seconds")]
|
|
||||||
pub pubtime: DateTime<Utc>,
|
|
||||||
pub attr: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
#[derive(Debug, serde::Deserialize)]
|
||||||
pub struct Upper {
|
pub struct Upper {
|
||||||
pub mid: i64,
|
pub mid: i64,
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
pub use analyzer::{BestStream, FilterOption};
|
pub use analyzer::{BestStream, FilterOption};
|
||||||
use anyhow::{bail, Result};
|
use anyhow::{bail, Result};
|
||||||
|
use chrono::serde::ts_seconds;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
pub use client::{BiliClient, Client};
|
pub use client::{BiliClient, Client};
|
||||||
|
pub use collection::{Collection, CollectionItem, CollectionType};
|
||||||
pub use credential::Credential;
|
pub use credential::Credential;
|
||||||
pub use danmaku::DanmakuOption;
|
pub use danmaku::DanmakuOption;
|
||||||
pub use error::BiliError;
|
pub use error::BiliError;
|
||||||
pub use favorite_list::{FavoriteList, FavoriteListInfo, VideoInfo};
|
pub use favorite_list::FavoriteList;
|
||||||
|
use favorite_list::Upper;
|
||||||
pub use video::{Dimension, PageInfo, Video};
|
pub use video::{Dimension, PageInfo, Video};
|
||||||
|
|
||||||
mod analyzer;
|
mod analyzer;
|
||||||
mod client;
|
mod client;
|
||||||
|
mod collection;
|
||||||
mod credential;
|
mod credential;
|
||||||
mod danmaku;
|
mod danmaku;
|
||||||
mod error;
|
mod error;
|
||||||
@@ -35,3 +40,55 @@ impl Validate for serde_json::Value {
|
|||||||
Ok(self)
|
Ok(self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
/// 注意此处的顺序是有要求的,因为对于 untagged 的 enum 来说,serde 会按照顺序匹配
|
||||||
|
/// > There is no explicit tag identifying which variant the data contains.
|
||||||
|
/// > Serde will try to match the data against each variant in order and the first one that deserializes successfully is the one returned.
|
||||||
|
pub enum VideoInfo {
|
||||||
|
/// 从视频详情接口获取的视频信息
|
||||||
|
View {
|
||||||
|
title: String,
|
||||||
|
bvid: String,
|
||||||
|
#[serde(rename = "desc")]
|
||||||
|
intro: String,
|
||||||
|
#[serde(rename = "pic")]
|
||||||
|
cover: String,
|
||||||
|
#[serde(rename = "owner")]
|
||||||
|
upper: Upper,
|
||||||
|
#[serde(with = "ts_seconds")]
|
||||||
|
ctime: DateTime<Utc>,
|
||||||
|
#[serde(rename = "pubdate", with = "ts_seconds")]
|
||||||
|
pubtime: DateTime<Utc>,
|
||||||
|
pages: Vec<PageInfo>,
|
||||||
|
state: i32,
|
||||||
|
},
|
||||||
|
/// 从收藏夹中获取的视频信息
|
||||||
|
Detail {
|
||||||
|
title: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
vtype: i32,
|
||||||
|
bvid: String,
|
||||||
|
intro: String,
|
||||||
|
cover: String,
|
||||||
|
upper: Upper,
|
||||||
|
#[serde(with = "ts_seconds")]
|
||||||
|
ctime: DateTime<Utc>,
|
||||||
|
#[serde(with = "ts_seconds")]
|
||||||
|
fav_time: DateTime<Utc>,
|
||||||
|
#[serde(with = "ts_seconds")]
|
||||||
|
pubtime: DateTime<Utc>,
|
||||||
|
attr: i32,
|
||||||
|
},
|
||||||
|
/// 从视频列表中获取的视频信息
|
||||||
|
Simple {
|
||||||
|
bvid: String,
|
||||||
|
#[serde(rename = "pic")]
|
||||||
|
cover: String,
|
||||||
|
#[serde(with = "ts_seconds")]
|
||||||
|
ctime: DateTime<Utc>,
|
||||||
|
#[serde(rename = "pubdate", with = "ts_seconds")]
|
||||||
|
pubtime: DateTime<Utc>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use reqwest::Method;
|
|||||||
use crate::bilibili::analyzer::PageAnalyzer;
|
use crate::bilibili::analyzer::PageAnalyzer;
|
||||||
use crate::bilibili::client::BiliClient;
|
use crate::bilibili::client::BiliClient;
|
||||||
use crate::bilibili::danmaku::{DanmakuElem, DanmakuWriter, DmSegMobileReply};
|
use crate::bilibili::danmaku::{DanmakuElem, DanmakuWriter, DmSegMobileReply};
|
||||||
use crate::bilibili::Validate;
|
use crate::bilibili::{Validate, VideoInfo};
|
||||||
|
|
||||||
static MASK_CODE: u64 = 2251799813685247;
|
static MASK_CODE: u64 = 2251799813685247;
|
||||||
static XOR_CODE: u64 = 23442827791579;
|
static XOR_CODE: u64 = 23442827791579;
|
||||||
@@ -61,6 +61,22 @@ impl<'a> Video<'a> {
|
|||||||
Self { client, aid, bvid }
|
Self { client, aid, bvid }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
/// 直接调用视频信息接口获取详细的视频信息
|
||||||
|
pub async fn get_view_info(&self) -> Result<VideoInfo> {
|
||||||
|
let mut res = self
|
||||||
|
.client
|
||||||
|
.request(Method::GET, "https://api.bilibili.com/x/web-interface/view")
|
||||||
|
.query(&[("aid", &self.aid), ("bvid", &self.bvid)])
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json::<serde_json::Value>()
|
||||||
|
.await?
|
||||||
|
.validate()?;
|
||||||
|
Ok(serde_json::from_value(res["data"].take())?)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_pages(&self) -> Result<Vec<PageInfo>> {
|
pub async fn get_pages(&self) -> Result<Vec<PageInfo>> {
|
||||||
let mut res = self
|
let mut res = self
|
||||||
.client
|
.client
|
||||||
@@ -158,8 +174,8 @@ fn bvid_to_aid(bvid: &str) -> u64 {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[tokio::test]
|
#[test]
|
||||||
async fn test_bvid_to_aid() {
|
fn test_bvid_to_aid() {
|
||||||
assert_eq!(bvid_to_aid("BV1Tr421n746"), 1401752220u64);
|
assert_eq!(bvid_to_aid("BV1Tr421n746"), 1401752220u64);
|
||||||
assert_eq!(bvid_to_aid("BV1sH4y1s7fe"), 1051892992u64);
|
assert_eq!(bvid_to_aid("BV1sH4y1s7fe"), 1051892992u64);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,30 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use arc_swap::ArcSwapOption;
|
use arc_swap::ArcSwapOption;
|
||||||
|
use handlebars::handlebars_helper;
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
|
use serde::de::{Deserializer, MapAccess, Visitor};
|
||||||
|
use serde::ser::SerializeMap;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::bilibili::{Credential, DanmakuOption, FilterOption};
|
use crate::bilibili::{CollectionItem, CollectionType, Credential, DanmakuOption, FilterOption};
|
||||||
|
|
||||||
|
pub static TEMPLATE: Lazy<handlebars::Handlebars> = Lazy::new(|| {
|
||||||
|
let mut handlebars = handlebars::Handlebars::new();
|
||||||
|
handlebars_helper!(truncate: |s: String, len: usize| {
|
||||||
|
if s.chars().count() > len {
|
||||||
|
s.chars().take(len).collect::<String>()
|
||||||
|
} else {
|
||||||
|
s.to_string()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
handlebars.register_helper("truncate", Box::new(truncate));
|
||||||
|
handlebars
|
||||||
|
.register_template_string("video", &CONFIG.video_name)
|
||||||
|
.unwrap();
|
||||||
|
handlebars.register_template_string("page", &CONFIG.page_name).unwrap();
|
||||||
|
handlebars
|
||||||
|
});
|
||||||
|
|
||||||
pub static CONFIG: Lazy<Config> = Lazy::new(|| {
|
pub static CONFIG: Lazy<Config> = Lazy::new(|| {
|
||||||
let config = Config::load().unwrap_or_else(|err| {
|
let config = Config::load().unwrap_or_else(|err| {
|
||||||
@@ -42,6 +62,12 @@ pub struct Config {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub danmaku_option: DanmakuOption,
|
pub danmaku_option: DanmakuOption,
|
||||||
pub favorite_list: HashMap<String, PathBuf>,
|
pub favorite_list: HashMap<String, PathBuf>,
|
||||||
|
#[serde(
|
||||||
|
default,
|
||||||
|
serialize_with = "serialize_collection_list",
|
||||||
|
deserialize_with = "deserialize_collection_list"
|
||||||
|
)]
|
||||||
|
pub collection_list: HashMap<CollectionItem, PathBuf>,
|
||||||
pub video_name: Cow<'static, str>,
|
pub video_name: Cow<'static, str>,
|
||||||
pub page_name: Cow<'static, str>,
|
pub page_name: Cow<'static, str>,
|
||||||
pub interval: u64,
|
pub interval: u64,
|
||||||
@@ -65,6 +91,7 @@ impl Default for Config {
|
|||||||
filter_option: FilterOption::default(),
|
filter_option: FilterOption::default(),
|
||||||
danmaku_option: DanmakuOption::default(),
|
danmaku_option: DanmakuOption::default(),
|
||||||
favorite_list: HashMap::new(),
|
favorite_list: HashMap::new(),
|
||||||
|
collection_list: HashMap::new(),
|
||||||
video_name: Cow::Borrowed("{{title}}"),
|
video_name: Cow::Borrowed("{{title}}"),
|
||||||
page_name: Cow::Borrowed("{{bvid}}"),
|
page_name: Cow::Borrowed("{{bvid}}"),
|
||||||
interval: 1200,
|
interval: 1200,
|
||||||
@@ -78,9 +105,9 @@ impl Config {
|
|||||||
/// 简单的预检查
|
/// 简单的预检查
|
||||||
pub fn check(&self) {
|
pub fn check(&self) {
|
||||||
let mut ok = true;
|
let mut ok = true;
|
||||||
if self.favorite_list.is_empty() {
|
if self.favorite_list.is_empty() && self.collection_list.is_empty() {
|
||||||
ok = false;
|
ok = false;
|
||||||
error!("未设置需监听的收藏夹,程序空转没有意义");
|
error!("未设置需监听的收藏夹或视频合集,程序空转没有意义");
|
||||||
}
|
}
|
||||||
for path in self.favorite_list.values() {
|
for path in self.favorite_list.values() {
|
||||||
if !path.is_absolute() {
|
if !path.is_absolute() {
|
||||||
@@ -141,6 +168,75 @@ impl Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn serialize_collection_list<S>(
|
||||||
|
collection_list: &HashMap<CollectionItem, PathBuf>,
|
||||||
|
serializer: S,
|
||||||
|
) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: serde::Serializer,
|
||||||
|
{
|
||||||
|
let mut map = serializer.serialize_map(Some(collection_list.len()))?;
|
||||||
|
for (k, v) in collection_list {
|
||||||
|
let prefix = match k.collection_type {
|
||||||
|
CollectionType::Series => "series",
|
||||||
|
CollectionType::Season => "season",
|
||||||
|
};
|
||||||
|
map.serialize_entry(&[prefix, &k.mid, &k.sid].join(":"), v)?;
|
||||||
|
}
|
||||||
|
map.end()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_collection_list<'de, D>(deserializer: D) -> Result<HashMap<CollectionItem, PathBuf>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
struct CollectionListVisitor;
|
||||||
|
|
||||||
|
impl<'de> Visitor<'de> for CollectionListVisitor {
|
||||||
|
type Value = HashMap<CollectionItem, PathBuf>;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
|
formatter.write_str("a map of collection list")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
|
||||||
|
where
|
||||||
|
A: MapAccess<'de>,
|
||||||
|
{
|
||||||
|
let mut collection_list = HashMap::new();
|
||||||
|
while let Some((key, value)) = map.next_entry::<String, PathBuf>()? {
|
||||||
|
let collection_item = match key.split(':').collect::<Vec<&str>>().as_slice() {
|
||||||
|
[prefix, mid, sid] => {
|
||||||
|
let collection_type = match *prefix {
|
||||||
|
"series" => CollectionType::Series,
|
||||||
|
"season" => CollectionType::Season,
|
||||||
|
_ => {
|
||||||
|
return Err(serde::de::Error::custom(
|
||||||
|
"invalid collection type, should be series or season",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
CollectionItem {
|
||||||
|
mid: mid.to_string(),
|
||||||
|
sid: sid.to_string(),
|
||||||
|
collection_type,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(serde::de::Error::custom(
|
||||||
|
"invalid collection key, should be series:mid:sid or season:mid:sid",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
collection_list.insert(collection_item, value);
|
||||||
|
}
|
||||||
|
Ok(collection_list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deserializer.deserialize_map(CollectionListVisitor)
|
||||||
|
}
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
pub mod command;
|
|
||||||
pub mod status;
|
|
||||||
pub mod utils;
|
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use bili_sync_migration::{Migrator, MigratorTrait};
|
use bili_sync_migration::{Migrator, MigratorTrait};
|
||||||
use sea_orm::{ConnectOptions, Database, DatabaseConnection};
|
use sea_orm::{ConnectOptions, Database, DatabaseConnection};
|
||||||
use tokio::fs;
|
|
||||||
|
|
||||||
use crate::config::CONFIG_DIR;
|
use crate::config::CONFIG_DIR;
|
||||||
|
|
||||||
|
fn database_url() -> String {
|
||||||
|
format!("sqlite://{}?mode=rwc", CONFIG_DIR.join("data.sqlite").to_string_lossy())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn database_connection() -> Result<DatabaseConnection> {
|
pub async fn database_connection() -> Result<DatabaseConnection> {
|
||||||
let target = CONFIG_DIR.join("data.sqlite");
|
let mut option = ConnectOptions::new(database_url());
|
||||||
fs::create_dir_all(&*CONFIG_DIR).await?;
|
|
||||||
let mut option = ConnectOptions::new(format!("sqlite://{}?mode=rwc", target.to_str().unwrap()));
|
|
||||||
option
|
option
|
||||||
.max_connections(100)
|
.max_connections(100)
|
||||||
.min_connections(5)
|
.min_connections(5)
|
||||||
@@ -15,6 +17,9 @@ pub async fn database_connection() -> Result<DatabaseConnection> {
|
|||||||
Ok(Database::connect(option).await?)
|
Ok(Database::connect(option).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn migrate_database(connection: &DatabaseConnection) -> Result<()> {
|
pub async fn migrate_database() -> Result<()> {
|
||||||
Ok(Migrator::up(connection, None).await?)
|
// 注意此处使用内部构造的 DatabaseConnection,而不是通过 database_connection() 获取
|
||||||
|
// 这是因为使用多个连接的 Connection 会导致奇怪的迁移顺序问题,而使用默认的连接选项不会
|
||||||
|
let connection = Database::connect(database_url()).await?;
|
||||||
|
Ok(Migrator::up(&connection, None).await?)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,34 @@
|
|||||||
#[macro_use]
|
#[macro_use]
|
||||||
extern crate tracing;
|
extern crate tracing;
|
||||||
|
|
||||||
|
mod adapter;
|
||||||
mod bilibili;
|
mod bilibili;
|
||||||
mod config;
|
mod config;
|
||||||
mod core;
|
|
||||||
mod database;
|
mod database;
|
||||||
mod downloader;
|
mod downloader;
|
||||||
mod error;
|
mod error;
|
||||||
|
mod utils;
|
||||||
|
mod workflow;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use config::ARGS;
|
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use tokio::time;
|
use tokio::time;
|
||||||
|
|
||||||
|
use crate::adapter::Args;
|
||||||
use crate::bilibili::BiliClient;
|
use crate::bilibili::BiliClient;
|
||||||
use crate::config::CONFIG;
|
use crate::config::{ARGS, CONFIG};
|
||||||
use crate::core::command::process_favorite_list;
|
|
||||||
use crate::core::utils::init_logger;
|
|
||||||
use crate::database::{database_connection, migrate_database};
|
use crate::database::{database_connection, migrate_database};
|
||||||
|
use crate::utils::init_logger;
|
||||||
|
use crate::workflow::process_video_list;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
Lazy::force(&ARGS);
|
|
||||||
init_logger(&ARGS.log_level);
|
init_logger(&ARGS.log_level);
|
||||||
Lazy::force(&CONFIG);
|
Lazy::force(&CONFIG);
|
||||||
|
migrate_database().await.expect("数据库迁移失败");
|
||||||
|
let connection = database_connection().await.expect("获取数据库连接失败");
|
||||||
let mut anchor = chrono::Local::now().date_naive();
|
let mut anchor = chrono::Local::now().date_naive();
|
||||||
let bili_client = BiliClient::new();
|
let bili_client = BiliClient::new();
|
||||||
let connection = database_connection().await.unwrap();
|
|
||||||
migrate_database(&connection).await.unwrap();
|
|
||||||
loop {
|
loop {
|
||||||
if let Err(e) = bili_client.is_login().await {
|
if let Err(e) = bili_client.is_login().await {
|
||||||
error!("检查登录状态时遇到错误:{e},等待下一轮执行");
|
error!("检查登录状态时遇到错误:{e},等待下一轮执行");
|
||||||
@@ -44,12 +44,20 @@ async fn main() {
|
|||||||
anchor = chrono::Local::now().date_naive();
|
anchor = chrono::Local::now().date_naive();
|
||||||
}
|
}
|
||||||
for (fid, path) in &CONFIG.favorite_list {
|
for (fid, path) in &CONFIG.favorite_list {
|
||||||
if let Err(e) = process_favorite_list(&bili_client, fid, path, &connection).await {
|
if let Err(e) = process_video_list(Args::Favorite { fid }, &bili_client, path, &connection).await {
|
||||||
// 可预期的错误都被内部处理了,这里漏出来应该是大问题
|
|
||||||
error!("处理收藏夹 {fid} 时遇到非预期的错误:{e}");
|
error!("处理收藏夹 {fid} 时遇到非预期的错误:{e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
info!("所有收藏夹处理完毕,等待下一轮执行");
|
info!("所有收藏夹处理完毕");
|
||||||
time::sleep(Duration::from_secs(CONFIG.interval)).await;
|
for (collection_item, path) in &CONFIG.collection_list {
|
||||||
|
if let Err(e) =
|
||||||
|
process_video_list(Args::Collection { collection_item }, &bili_client, path, &connection).await
|
||||||
|
{
|
||||||
|
error!("处理合集 {collection_item:?} 时遇到非预期的错误:{e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info!("所有合集处理完毕");
|
||||||
|
info!("本轮任务执行完毕,等待下一轮执行");
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(CONFIG.interval)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
use sea_orm::ActiveValue::NotSet;
|
||||||
|
use sea_orm::{IntoActiveModel, Set};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::bilibili::VideoInfo;
|
||||||
|
use crate::utils::id_time_key;
|
||||||
|
|
||||||
|
impl VideoInfo {
|
||||||
|
/// 将 VideoInfo 转换为 ActiveModel
|
||||||
|
pub fn to_model(&self, base_model: Option<bili_sync_entity::video::Model>) -> bili_sync_entity::video::ActiveModel {
|
||||||
|
let base_model = match base_model {
|
||||||
|
Some(base_model) => base_model.into_active_model(),
|
||||||
|
None => {
|
||||||
|
let mut tmp_model = bili_sync_entity::video::Model::default().into_active_model();
|
||||||
|
// 注意此处要把 id 设置成 NotSet,否则 id 会是 Unchanged(0)
|
||||||
|
tmp_model.id = NotSet;
|
||||||
|
tmp_model
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match self {
|
||||||
|
VideoInfo::Simple {
|
||||||
|
bvid,
|
||||||
|
cover,
|
||||||
|
ctime,
|
||||||
|
pubtime,
|
||||||
|
} => bili_sync_entity::video::ActiveModel {
|
||||||
|
bvid: Set(bvid.clone()),
|
||||||
|
cover: Set(cover.clone()),
|
||||||
|
ctime: Set(ctime.naive_utc()),
|
||||||
|
pubtime: Set(pubtime.naive_utc()),
|
||||||
|
category: Set(2), // 视频合集里的内容类型肯定是视频
|
||||||
|
valid: Set(true),
|
||||||
|
..base_model
|
||||||
|
},
|
||||||
|
VideoInfo::Detail {
|
||||||
|
title,
|
||||||
|
vtype,
|
||||||
|
bvid,
|
||||||
|
intro,
|
||||||
|
cover,
|
||||||
|
upper,
|
||||||
|
ctime,
|
||||||
|
fav_time,
|
||||||
|
pubtime,
|
||||||
|
attr,
|
||||||
|
} => bili_sync_entity::video::ActiveModel {
|
||||||
|
bvid: Set(bvid.clone()),
|
||||||
|
name: Set(title.clone()),
|
||||||
|
category: Set(*vtype),
|
||||||
|
intro: Set(intro.clone()),
|
||||||
|
cover: Set(cover.clone()),
|
||||||
|
ctime: Set(ctime.naive_utc()),
|
||||||
|
pubtime: Set(pubtime.naive_utc()),
|
||||||
|
favtime: Set(fav_time.naive_utc()),
|
||||||
|
download_status: Set(0),
|
||||||
|
valid: Set(*attr == 0),
|
||||||
|
tags: Set(None),
|
||||||
|
single_page: Set(None),
|
||||||
|
upper_id: Set(upper.mid),
|
||||||
|
upper_name: Set(upper.name.clone()),
|
||||||
|
upper_face: Set(upper.face.clone()),
|
||||||
|
..base_model
|
||||||
|
},
|
||||||
|
VideoInfo::View {
|
||||||
|
title,
|
||||||
|
bvid,
|
||||||
|
intro,
|
||||||
|
cover,
|
||||||
|
upper,
|
||||||
|
ctime,
|
||||||
|
pubtime,
|
||||||
|
state,
|
||||||
|
..
|
||||||
|
} => bili_sync_entity::video::ActiveModel {
|
||||||
|
bvid: Set(bvid.clone()),
|
||||||
|
name: Set(title.clone()),
|
||||||
|
category: Set(2), // 视频合集里的内容类型肯定是视频
|
||||||
|
intro: Set(intro.clone()),
|
||||||
|
cover: Set(cover.clone()),
|
||||||
|
ctime: Set(ctime.naive_utc()),
|
||||||
|
pubtime: Set(pubtime.naive_utc()),
|
||||||
|
favtime: Set(pubtime.naive_utc()), // 合集不包括 fav_time,使用发布时间代替
|
||||||
|
download_status: Set(0),
|
||||||
|
valid: Set(*state == 0),
|
||||||
|
tags: Set(None),
|
||||||
|
single_page: Set(None),
|
||||||
|
upper_id: Set(upper.mid),
|
||||||
|
upper_name: Set(upper.name.clone()),
|
||||||
|
upper_face: Set(upper.face.clone()),
|
||||||
|
..base_model
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_fmt_args(&self) -> Option<serde_json::Value> {
|
||||||
|
match self {
|
||||||
|
VideoInfo::Simple { .. } => None, // 不能从简单的视频信息中构造格式化参数
|
||||||
|
VideoInfo::Detail { title, bvid, upper, .. } => Some(json!({
|
||||||
|
"bvid": &bvid,
|
||||||
|
"title": &title,
|
||||||
|
"upper_name": &upper.name,
|
||||||
|
"upper_mid": &upper.mid,
|
||||||
|
})),
|
||||||
|
VideoInfo::View { title, bvid, upper, .. } => Some(json!({
|
||||||
|
"bvid": &bvid,
|
||||||
|
"title": &title,
|
||||||
|
"upper_name": &upper.name,
|
||||||
|
"upper_mid": &upper.mid,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn video_key(&self) -> String {
|
||||||
|
match self {
|
||||||
|
// 对于合集没有 fav_time,只能用 pubtime 代替
|
||||||
|
VideoInfo::Simple { bvid, pubtime, .. } => id_time_key(bvid, pubtime),
|
||||||
|
VideoInfo::Detail { bvid, fav_time, .. } => id_time_key(bvid, fav_time),
|
||||||
|
// 详情接口返回的数据仅用于填充详情,不会被作为 video_key
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bvid(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
VideoInfo::Simple { bvid, .. } => bvid,
|
||||||
|
VideoInfo::Detail { bvid, .. } => bvid,
|
||||||
|
// 同上
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
pub mod convert;
|
||||||
|
pub mod model;
|
||||||
|
pub mod nfo;
|
||||||
|
pub mod status;
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use tracing_subscriber::util::SubscriberInitExt;
|
||||||
|
|
||||||
|
pub fn init_logger(log_level: &str) {
|
||||||
|
tracing_subscriber::fmt::Subscriber::builder()
|
||||||
|
.with_env_filter(tracing_subscriber::EnvFilter::builder().parse_lossy(log_level))
|
||||||
|
.with_timer(tracing_subscriber::fmt::time::ChronoLocal::new(
|
||||||
|
"%Y-%m-%d %H:%M:%S%.3f".to_owned(),
|
||||||
|
))
|
||||||
|
.finish()
|
||||||
|
.try_init()
|
||||||
|
.expect("初始化日志失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成视频的唯一标记,均由 bvid 和时间戳构成
|
||||||
|
pub fn id_time_key(bvid: &String, time: &DateTime<Utc>) -> String {
|
||||||
|
format!("{}-{}", bvid, time.timestamp())
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use bili_sync_entity::*;
|
||||||
|
use bili_sync_migration::OnConflict;
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use sea_orm::ActiveValue::Set;
|
||||||
|
|
||||||
|
use crate::adapter::{unique_video_columns, VideoListModel};
|
||||||
|
use crate::bilibili::{PageInfo, VideoInfo};
|
||||||
|
|
||||||
|
/// 尝试创建 Video Model,如果发生冲突则忽略
|
||||||
|
pub async fn create_videos(
|
||||||
|
videos_info: &[VideoInfo],
|
||||||
|
video_list_model: &dyn VideoListModel,
|
||||||
|
connection: &DatabaseConnection,
|
||||||
|
) -> Result<()> {
|
||||||
|
let video_models = videos_info
|
||||||
|
.iter()
|
||||||
|
.map(|v| video_list_model.video_model_by_info(v, None))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
video::Entity::insert_many(video_models)
|
||||||
|
.on_conflict(OnConflict::columns(unique_video_columns()).do_nothing().to_owned())
|
||||||
|
.do_nothing()
|
||||||
|
.exec(connection)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建视频的所有分 P
|
||||||
|
pub async fn create_video_pages(
|
||||||
|
pages_info: &[PageInfo],
|
||||||
|
video_model: &video::Model,
|
||||||
|
connection: &impl ConnectionTrait,
|
||||||
|
) -> Result<()> {
|
||||||
|
let page_models = pages_info
|
||||||
|
.iter()
|
||||||
|
.map(move |p| {
|
||||||
|
let (width, height) = match &p.dimension {
|
||||||
|
Some(d) => {
|
||||||
|
if d.rotate == 0 {
|
||||||
|
(Some(d.width), Some(d.height))
|
||||||
|
} else {
|
||||||
|
(Some(d.height), Some(d.width))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => (None, None),
|
||||||
|
};
|
||||||
|
page::ActiveModel {
|
||||||
|
video_id: Set(video_model.id),
|
||||||
|
cid: Set(p.cid),
|
||||||
|
pid: Set(p.page),
|
||||||
|
name: Set(p.name.clone()),
|
||||||
|
width: Set(width),
|
||||||
|
height: Set(height),
|
||||||
|
duration: Set(p.duration),
|
||||||
|
image: Set(p.first_frame.clone()),
|
||||||
|
download_status: Set(0),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<page::ActiveModel>>();
|
||||||
|
page::Entity::insert_many(page_models)
|
||||||
|
.on_conflict(
|
||||||
|
OnConflict::columns([page::Column::VideoId, page::Column::Pid])
|
||||||
|
.do_nothing()
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.do_nothing()
|
||||||
|
.exec(connection)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新视频 model 的下载状态
|
||||||
|
pub async fn update_videos_model(videos: Vec<video::ActiveModel>, connection: &DatabaseConnection) -> Result<()> {
|
||||||
|
video::Entity::insert_many(videos)
|
||||||
|
.on_conflict(
|
||||||
|
OnConflict::column(video::Column::Id)
|
||||||
|
.update_column(video::Column::DownloadStatus)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.exec(connection)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新视频页 model 的下载状态
|
||||||
|
pub async fn update_pages_model(pages: Vec<page::ActiveModel>, connection: &DatabaseConnection) -> Result<()> {
|
||||||
|
let query = page::Entity::insert_many(pages).on_conflict(
|
||||||
|
OnConflict::column(page::Column::Id)
|
||||||
|
.update_columns([page::Column::DownloadStatus, page::Column::Path])
|
||||||
|
.to_owned(),
|
||||||
|
);
|
||||||
|
query.exec(connection).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,42 +1,11 @@
|
|||||||
use std::collections::HashSet;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use bili_sync_entity::*;
|
use bili_sync_entity::*;
|
||||||
use bili_sync_migration::OnConflict;
|
|
||||||
use filenamify::filenamify;
|
|
||||||
use handlebars::handlebars_helper;
|
|
||||||
use once_cell::sync::Lazy;
|
|
||||||
use quick_xml::events::{BytesCData, BytesText};
|
use quick_xml::events::{BytesCData, BytesText};
|
||||||
use quick_xml::writer::Writer;
|
use quick_xml::writer::Writer;
|
||||||
use quick_xml::Error;
|
use quick_xml::Error;
|
||||||
use sea_orm::entity::prelude::*;
|
|
||||||
use sea_orm::ActiveValue::Set;
|
|
||||||
use sea_orm::QuerySelect;
|
|
||||||
use serde_json::json;
|
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
use tracing_subscriber::util::SubscriberInitExt;
|
|
||||||
|
|
||||||
use crate::bilibili::{FavoriteListInfo, PageInfo, VideoInfo};
|
use crate::config::NFOTimeType;
|
||||||
use crate::config::{NFOTimeType, CONFIG};
|
|
||||||
use crate::core::status::Status;
|
|
||||||
|
|
||||||
pub static TEMPLATE: Lazy<handlebars::Handlebars> = Lazy::new(|| {
|
|
||||||
let mut handlebars = handlebars::Handlebars::new();
|
|
||||||
handlebars_helper!(truncate: |s: String, len: usize| {
|
|
||||||
if s.chars().count() > len {
|
|
||||||
s.chars().take(len).collect::<String>()
|
|
||||||
} else {
|
|
||||||
s.to_string()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
handlebars.register_helper("truncate", Box::new(truncate));
|
|
||||||
handlebars
|
|
||||||
.register_template_string("video", &CONFIG.video_name)
|
|
||||||
.unwrap();
|
|
||||||
handlebars.register_template_string("page", &CONFIG.page_name).unwrap();
|
|
||||||
handlebars
|
|
||||||
});
|
|
||||||
|
|
||||||
#[allow(clippy::upper_case_acronyms)]
|
#[allow(clippy::upper_case_acronyms)]
|
||||||
pub enum NFOMode {
|
pub enum NFOMode {
|
||||||
@@ -53,225 +22,6 @@ pub enum ModelWrapper<'a> {
|
|||||||
|
|
||||||
pub struct NFOSerializer<'a>(pub ModelWrapper<'a>, pub NFOMode);
|
pub struct NFOSerializer<'a>(pub ModelWrapper<'a>, pub NFOMode);
|
||||||
|
|
||||||
/// 根据获得的收藏夹信息,插入或更新数据库中的收藏夹,并返回收藏夹对象
|
|
||||||
pub async fn handle_favorite_info(
|
|
||||||
info: &FavoriteListInfo,
|
|
||||||
path: &Path,
|
|
||||||
connection: &DatabaseConnection,
|
|
||||||
) -> Result<favorite::Model> {
|
|
||||||
favorite::Entity::insert(favorite::ActiveModel {
|
|
||||||
f_id: Set(info.id),
|
|
||||||
name: Set(info.title.clone()),
|
|
||||||
path: Set(path.to_string_lossy().to_string()),
|
|
||||||
..Default::default()
|
|
||||||
})
|
|
||||||
.on_conflict(
|
|
||||||
OnConflict::column(favorite::Column::FId)
|
|
||||||
.update_columns([favorite::Column::Name, favorite::Column::Path])
|
|
||||||
.to_owned(),
|
|
||||||
)
|
|
||||||
.exec(connection)
|
|
||||||
.await?;
|
|
||||||
Ok(favorite::Entity::find()
|
|
||||||
.filter(favorite::Column::FId.eq(info.id))
|
|
||||||
.one(connection)
|
|
||||||
.await?
|
|
||||||
.unwrap())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取数据库中存在的与该视频 favorite_id 和 bvid 重合的视频中的 bvid 和 favtime
|
|
||||||
/// 如果 bvid 和 favtime 均相同,说明到达了上次处理到的位置
|
|
||||||
pub async fn exist_labels(
|
|
||||||
videos_info: &[VideoInfo],
|
|
||||||
favorite_model: &favorite::Model,
|
|
||||||
connection: &DatabaseConnection,
|
|
||||||
) -> Result<HashSet<(String, DateTime)>> {
|
|
||||||
let bvids = videos_info.iter().map(|v| v.bvid.clone()).collect::<Vec<String>>();
|
|
||||||
let exist_labels = video::Entity::find()
|
|
||||||
.filter(
|
|
||||||
video::Column::FavoriteId
|
|
||||||
.eq(favorite_model.id)
|
|
||||||
.and(video::Column::Bvid.is_in(bvids)),
|
|
||||||
)
|
|
||||||
.select_only()
|
|
||||||
.columns([video::Column::Bvid, video::Column::Favtime])
|
|
||||||
.into_tuple()
|
|
||||||
.all(connection)
|
|
||||||
.await?
|
|
||||||
.into_iter()
|
|
||||||
.collect::<HashSet<(String, DateTime)>>();
|
|
||||||
Ok(exist_labels)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 尝试创建 Video Model,如果发生冲突则忽略
|
|
||||||
pub async fn create_videos(
|
|
||||||
videos_info: &[VideoInfo],
|
|
||||||
favorite: &favorite::Model,
|
|
||||||
connection: &DatabaseConnection,
|
|
||||||
) -> Result<()> {
|
|
||||||
let video_models = videos_info
|
|
||||||
.iter()
|
|
||||||
.map(move |v| video::ActiveModel {
|
|
||||||
favorite_id: Set(favorite.id),
|
|
||||||
bvid: Set(v.bvid.clone()),
|
|
||||||
name: Set(v.title.clone()),
|
|
||||||
path: Set(Path::new(&favorite.path)
|
|
||||||
.join(filenamify(
|
|
||||||
TEMPLATE
|
|
||||||
.render(
|
|
||||||
"video",
|
|
||||||
&json!({
|
|
||||||
"bvid": &v.bvid,
|
|
||||||
"title": &v.title,
|
|
||||||
"upper_name": &v.upper.name,
|
|
||||||
"upper_mid": &v.upper.mid,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.unwrap_or_else(|_| v.bvid.clone()),
|
|
||||||
))
|
|
||||||
.to_str()
|
|
||||||
.unwrap()
|
|
||||||
.to_owned()),
|
|
||||||
category: Set(v.vtype),
|
|
||||||
intro: Set(v.intro.clone()),
|
|
||||||
cover: Set(v.cover.clone()),
|
|
||||||
ctime: Set(v.ctime.naive_utc()),
|
|
||||||
pubtime: Set(v.pubtime.naive_utc()),
|
|
||||||
favtime: Set(v.fav_time.naive_utc()),
|
|
||||||
download_status: Set(0),
|
|
||||||
valid: Set(v.attr == 0),
|
|
||||||
tags: Set(None),
|
|
||||||
single_page: Set(None),
|
|
||||||
upper_id: Set(v.upper.mid),
|
|
||||||
upper_name: Set(v.upper.name.clone()),
|
|
||||||
upper_face: Set(v.upper.face.clone()),
|
|
||||||
..Default::default()
|
|
||||||
})
|
|
||||||
.collect::<Vec<video::ActiveModel>>();
|
|
||||||
video::Entity::insert_many(video_models)
|
|
||||||
.on_conflict(
|
|
||||||
OnConflict::columns([video::Column::FavoriteId, video::Column::Bvid])
|
|
||||||
.do_nothing()
|
|
||||||
.to_owned(),
|
|
||||||
)
|
|
||||||
.do_nothing()
|
|
||||||
.exec(connection)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn total_video_count(favorite_model: &favorite::Model, connection: &DatabaseConnection) -> Result<u64> {
|
|
||||||
Ok(video::Entity::find()
|
|
||||||
.filter(video::Column::FavoriteId.eq(favorite_model.id))
|
|
||||||
.count(connection)
|
|
||||||
.await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 筛选所有未
|
|
||||||
pub async fn filter_unfilled_videos(
|
|
||||||
favorite_model: &favorite::Model,
|
|
||||||
connection: &DatabaseConnection,
|
|
||||||
) -> Result<Vec<video::Model>> {
|
|
||||||
Ok(video::Entity::find()
|
|
||||||
.filter(
|
|
||||||
video::Column::FavoriteId
|
|
||||||
.eq(favorite_model.id)
|
|
||||||
.and(video::Column::Valid.eq(true))
|
|
||||||
.and(video::Column::DownloadStatus.eq(0))
|
|
||||||
.and(video::Column::Category.eq(2))
|
|
||||||
.and(video::Column::SinglePage.is_null()),
|
|
||||||
)
|
|
||||||
.all(connection)
|
|
||||||
.await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 创建视频的所有分 P
|
|
||||||
pub async fn create_video_pages(
|
|
||||||
pages_info: &[PageInfo],
|
|
||||||
video_model: &video::Model,
|
|
||||||
connection: &impl ConnectionTrait,
|
|
||||||
) -> Result<()> {
|
|
||||||
let page_models = pages_info
|
|
||||||
.iter()
|
|
||||||
.map(move |p| {
|
|
||||||
let (width, height) = match &p.dimension {
|
|
||||||
Some(d) => {
|
|
||||||
if d.rotate == 0 {
|
|
||||||
(Some(d.width), Some(d.height))
|
|
||||||
} else {
|
|
||||||
(Some(d.height), Some(d.width))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => (None, None),
|
|
||||||
};
|
|
||||||
page::ActiveModel {
|
|
||||||
video_id: Set(video_model.id),
|
|
||||||
cid: Set(p.cid),
|
|
||||||
pid: Set(p.page),
|
|
||||||
name: Set(p.name.clone()),
|
|
||||||
width: Set(width),
|
|
||||||
height: Set(height),
|
|
||||||
duration: Set(p.duration),
|
|
||||||
image: Set(p.first_frame.clone()),
|
|
||||||
download_status: Set(0),
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<Vec<page::ActiveModel>>();
|
|
||||||
page::Entity::insert_many(page_models)
|
|
||||||
.on_conflict(
|
|
||||||
OnConflict::columns([page::Column::VideoId, page::Column::Pid])
|
|
||||||
.do_nothing()
|
|
||||||
.to_owned(),
|
|
||||||
)
|
|
||||||
.do_nothing()
|
|
||||||
.exec(connection)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取所有未处理的视频和页
|
|
||||||
pub async fn unhandled_videos_pages(
|
|
||||||
favorite_model: &favorite::Model,
|
|
||||||
connection: &DatabaseConnection,
|
|
||||||
) -> Result<Vec<(video::Model, Vec<page::Model>)>> {
|
|
||||||
Ok(video::Entity::find()
|
|
||||||
.filter(
|
|
||||||
video::Column::FavoriteId
|
|
||||||
.eq(favorite_model.id)
|
|
||||||
.and(video::Column::Valid.eq(true))
|
|
||||||
.and(video::Column::DownloadStatus.lt(Status::handled()))
|
|
||||||
.and(video::Column::Category.eq(2))
|
|
||||||
.and(video::Column::SinglePage.is_not_null()),
|
|
||||||
)
|
|
||||||
.find_with_related(page::Entity)
|
|
||||||
.all(connection)
|
|
||||||
.await?)
|
|
||||||
}
|
|
||||||
/// 更新视频 model 的下载状态
|
|
||||||
pub async fn update_videos_model(videos: Vec<video::ActiveModel>, connection: &DatabaseConnection) -> Result<()> {
|
|
||||||
video::Entity::insert_many(videos)
|
|
||||||
.on_conflict(
|
|
||||||
OnConflict::column(video::Column::Id)
|
|
||||||
.update_column(video::Column::DownloadStatus)
|
|
||||||
.to_owned(),
|
|
||||||
)
|
|
||||||
.exec(connection)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 更新视频页 model 的下载状态
|
|
||||||
pub async fn update_pages_model(pages: Vec<page::ActiveModel>, connection: &DatabaseConnection) -> Result<()> {
|
|
||||||
let query = page::Entity::insert_many(pages).on_conflict(
|
|
||||||
OnConflict::column(page::Column::Id)
|
|
||||||
.update_columns([page::Column::DownloadStatus, page::Column::Path])
|
|
||||||
.to_owned(),
|
|
||||||
);
|
|
||||||
query.exec(connection).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// serde xml 似乎不太好用,先这么裸着写
|
/// serde xml 似乎不太好用,先这么裸着写
|
||||||
/// (真是又臭又长啊
|
/// (真是又臭又长啊
|
||||||
impl<'a> NFOSerializer<'a> {
|
impl<'a> NFOSerializer<'a> {
|
||||||
@@ -483,17 +233,6 @@ impl<'a> NFOSerializer<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn init_logger(log_level: &str) {
|
|
||||||
tracing_subscriber::fmt::Subscriber::builder()
|
|
||||||
.with_env_filter(tracing_subscriber::EnvFilter::builder().parse_lossy(log_level))
|
|
||||||
.with_timer(tracing_subscriber::fmt::time::ChronoLocal::new(
|
|
||||||
"%Y-%m-%d %H:%M:%S%.3f".to_owned(),
|
|
||||||
))
|
|
||||||
.finish()
|
|
||||||
.try_init()
|
|
||||||
.expect("初始化日志失败");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1,158 +1,96 @@
|
|||||||
|
#![allow(dead_code, unused_variables)]
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
|
|
||||||
use anyhow::{bail, Result};
|
use anyhow::{bail, Result};
|
||||||
use bili_sync_entity::{favorite, page, video};
|
use bili_sync_entity::{page, video};
|
||||||
use filenamify::filenamify;
|
use filenamify::filenamify;
|
||||||
use futures::stream::{FuturesOrdered, FuturesUnordered};
|
use futures::stream::{FuturesOrdered, FuturesUnordered};
|
||||||
use futures::{pin_mut, Future, StreamExt};
|
use futures::{Future, Stream, StreamExt};
|
||||||
use sea_orm::entity::prelude::*;
|
use sea_orm::entity::prelude::*;
|
||||||
use sea_orm::ActiveValue::Set;
|
use sea_orm::ActiveValue::Set;
|
||||||
use sea_orm::TransactionTrait;
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
use tokio::sync::{Mutex, Semaphore};
|
use tokio::sync::{Mutex, Semaphore};
|
||||||
|
|
||||||
use crate::bilibili::{BestStream, BiliClient, BiliError, Dimension, FavoriteList, PageInfo, Video};
|
use crate::adapter::{video_list_from, Args, VideoListModel};
|
||||||
use crate::config::{ARGS, CONFIG};
|
use crate::bilibili::{BestStream, BiliClient, BiliError, Dimension, PageInfo, Video, VideoInfo};
|
||||||
use crate::core::status::{PageStatus, VideoStatus};
|
use crate::config::{ARGS, CONFIG, TEMPLATE};
|
||||||
use crate::core::utils::{
|
|
||||||
create_video_pages, create_videos, exist_labels, filter_unfilled_videos, handle_favorite_info, total_video_count,
|
|
||||||
unhandled_videos_pages, update_pages_model, update_videos_model, ModelWrapper, NFOMode, NFOSerializer, TEMPLATE,
|
|
||||||
};
|
|
||||||
use crate::downloader::Downloader;
|
use crate::downloader::Downloader;
|
||||||
use crate::error::{DownloadAbortError, ProcessPageError};
|
use crate::error::{DownloadAbortError, ProcessPageError};
|
||||||
|
use crate::utils::model::{create_videos, update_pages_model, update_videos_model};
|
||||||
|
use crate::utils::nfo::{ModelWrapper, NFOMode, NFOSerializer};
|
||||||
|
use crate::utils::status::{PageStatus, VideoStatus};
|
||||||
|
|
||||||
/// 处理某个收藏夹,首先刷新收藏夹信息,然后下载收藏夹中未下载成功的视频
|
pub async fn process_video_list(
|
||||||
pub async fn process_favorite_list(
|
args: Args<'_>,
|
||||||
bili_client: &BiliClient,
|
bili_client: &BiliClient,
|
||||||
fid: &str,
|
|
||||||
path: &Path,
|
path: &Path,
|
||||||
connection: &DatabaseConnection,
|
connection: &DatabaseConnection,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let favorite_model = refresh_favorite_list(bili_client, fid, path, connection).await?;
|
let (video_list_model, video_streams) = video_list_from(args, path, bili_client, connection).await?;
|
||||||
let favorite_model = fetch_video_details(bili_client, favorite_model, connection).await?;
|
let video_list_model = refresh_video_list(bili_client, video_list_model, video_streams, connection).await?;
|
||||||
|
let video_list_model = fetch_video_details(bili_client, video_list_model, connection).await?;
|
||||||
if ARGS.scan_only {
|
if ARGS.scan_only {
|
||||||
warn!("已开启仅扫描模式,跳过视频下载...");
|
warn!("已开启仅扫描模式,跳过视频下载...");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
download_unprocessed_videos(bili_client, favorite_model, connection).await
|
download_unprocessed_videos(bili_client, video_list_model, connection).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取收藏夹 Model,从收藏夹列表中获取所有新添加的视频,将其写入数据库
|
/// 请求接口,获取视频列表中所有新添加的视频信息,将其写入数据库
|
||||||
pub async fn refresh_favorite_list(
|
pub async fn refresh_video_list<'a>(
|
||||||
bili_client: &BiliClient,
|
bili_client: &'a BiliClient,
|
||||||
fid: &str,
|
video_list_model: Box<dyn VideoListModel>,
|
||||||
path: &Path,
|
video_streams: Pin<Box<dyn Stream<Item = VideoInfo> + 'a>>,
|
||||||
connection: &DatabaseConnection,
|
connection: &DatabaseConnection,
|
||||||
) -> Result<favorite::Model> {
|
) -> Result<Box<dyn VideoListModel>> {
|
||||||
let bili_favorite_list = FavoriteList::new(bili_client, fid.to_owned());
|
video_list_model.log_refresh_video_start();
|
||||||
let favorite_list_info = bili_favorite_list.get_info().await?;
|
let mut video_streams = video_streams.chunks(10);
|
||||||
let favorite_model = handle_favorite_info(&favorite_list_info, path, connection).await?;
|
|
||||||
info!("开始扫描收藏夹: {} - {}...", favorite_model.f_id, favorite_model.name);
|
|
||||||
// 每十个视频一组,避免太多的数据库操作
|
|
||||||
let video_stream = bili_favorite_list.into_video_stream().chunks(10);
|
|
||||||
pin_mut!(video_stream);
|
|
||||||
let mut got_count = 0;
|
let mut got_count = 0;
|
||||||
let total_count = total_video_count(&favorite_model, connection).await?;
|
let mut new_count = video_list_model.video_count(connection).await?;
|
||||||
while let Some(videos_info) = video_stream.next().await {
|
while let Some(videos_info) = video_streams.next().await {
|
||||||
got_count += videos_info.len();
|
got_count += videos_info.len();
|
||||||
let exist_labels = exist_labels(&videos_info, &favorite_model, connection).await?;
|
let exist_labels = video_list_model.exist_labels(&videos_info, connection).await?;
|
||||||
// 如果发现有视频的收藏时间和 bvid 和数据库中重合,说明到达了上次处理到的地方,可以直接退出
|
// 如果发现有视频的收藏时间和 bvid 和数据库中重合,说明到达了上次处理到的地方,可以直接退出
|
||||||
let should_break = videos_info
|
let should_break = videos_info.iter().any(|v| exist_labels.contains(&v.video_key()));
|
||||||
.iter()
|
|
||||||
.any(|v| exist_labels.contains(&(v.bvid.clone(), v.fav_time.naive_utc())));
|
|
||||||
// 将视频信息写入数据库
|
// 将视频信息写入数据库
|
||||||
create_videos(&videos_info, &favorite_model, connection).await?;
|
create_videos(&videos_info, video_list_model.as_ref(), connection).await?;
|
||||||
if should_break {
|
if should_break {
|
||||||
info!("到达上一次处理的位置,提前中止");
|
info!("到达上一次处理的位置,提前中止");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let total_count = total_video_count(&favorite_model, connection).await? - total_count;
|
new_count = video_list_model.video_count(connection).await? - new_count;
|
||||||
info!(
|
video_list_model.log_refresh_video_end(got_count, new_count);
|
||||||
"扫描收藏夹: {} - {} 完成, 获取了 {} 条视频, 其中有 {} 条新视频",
|
Ok(video_list_model)
|
||||||
favorite_model.f_id, favorite_model.name, got_count, total_count
|
|
||||||
);
|
|
||||||
Ok(favorite_model)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 筛选出所有没有获取到分页信息和 tag 的视频,请求分页信息和 tag 并写入数据库
|
/// 筛选出所有未获取到全部信息的视频,尝试补充其详细信息
|
||||||
pub async fn fetch_video_details(
|
pub async fn fetch_video_details(
|
||||||
bili_client: &BiliClient,
|
bili_client: &BiliClient,
|
||||||
favorite_model: favorite::Model,
|
video_list_model: Box<dyn VideoListModel>,
|
||||||
connection: &DatabaseConnection,
|
connection: &DatabaseConnection,
|
||||||
) -> Result<favorite::Model> {
|
) -> Result<Box<dyn VideoListModel>> {
|
||||||
info!(
|
video_list_model.log_fetch_video_start();
|
||||||
"开始获取收藏夹 {} - {} 的视频与分页信息...",
|
let videos_model = video_list_model.unfilled_videos(connection).await?;
|
||||||
favorite_model.f_id, favorite_model.name
|
video_list_model
|
||||||
);
|
.fetch_videos_detail(bili_client, videos_model, connection)
|
||||||
let videos_model = filter_unfilled_videos(&favorite_model, connection).await?;
|
.await?;
|
||||||
for video_model in videos_model {
|
video_list_model.log_fetch_video_end();
|
||||||
let bili_video = Video::new(bili_client, video_model.bvid.clone());
|
Ok(video_list_model)
|
||||||
let tags = match bili_video.get_tags().await {
|
|
||||||
Ok(tags) => tags,
|
|
||||||
Err(e) => {
|
|
||||||
error!(
|
|
||||||
"获取视频 {} - {} 的标签失败,错误为:{}",
|
|
||||||
&video_model.bvid, &video_model.name, e
|
|
||||||
);
|
|
||||||
if let Some(BiliError::RequestFailed(code, _)) = e.downcast_ref::<BiliError>() {
|
|
||||||
if *code == -404 {
|
|
||||||
let mut video_active_model: video::ActiveModel = video_model.into();
|
|
||||||
video_active_model.valid = Set(false);
|
|
||||||
video_active_model.save(connection).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let pages_info = match bili_video.get_pages().await {
|
|
||||||
Ok(pages) => pages,
|
|
||||||
Err(e) => {
|
|
||||||
error!(
|
|
||||||
"获取视频 {} - {} 的分页信息失败,错误为:{}",
|
|
||||||
&video_model.bvid, &video_model.name, e
|
|
||||||
);
|
|
||||||
if let Some(BiliError::RequestFailed(code, _)) = e.downcast_ref::<BiliError>() {
|
|
||||||
if *code == -404 {
|
|
||||||
let mut video_active_model: video::ActiveModel = video_model.into();
|
|
||||||
video_active_model.valid = Set(false);
|
|
||||||
video_active_model.save(connection).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let txn = connection.begin().await?;
|
|
||||||
// 将分页信息写入数据库
|
|
||||||
create_video_pages(&pages_info, &video_model, &txn).await?;
|
|
||||||
// 将页标记和 tag 写入数据库
|
|
||||||
let mut video_active_model: video::ActiveModel = video_model.into();
|
|
||||||
video_active_model.single_page = Set(Some(pages_info.len() == 1));
|
|
||||||
video_active_model.tags = Set(Some(serde_json::to_value(tags).unwrap()));
|
|
||||||
video_active_model.save(&txn).await?;
|
|
||||||
txn.commit().await?;
|
|
||||||
}
|
|
||||||
info!(
|
|
||||||
"获取收藏夹 {} - {} 的视频与分页信息完成",
|
|
||||||
favorite_model.f_id, favorite_model.name
|
|
||||||
);
|
|
||||||
Ok(favorite_model)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 下载所有未处理成功的视频
|
/// 下载所有未处理成功的视频
|
||||||
pub async fn download_unprocessed_videos(
|
pub async fn download_unprocessed_videos(
|
||||||
bili_client: &BiliClient,
|
bili_client: &BiliClient,
|
||||||
favorite_model: favorite::Model,
|
video_list_model: Box<dyn VideoListModel>,
|
||||||
connection: &DatabaseConnection,
|
connection: &DatabaseConnection,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
info!(
|
video_list_model.log_download_video_start();
|
||||||
"开始下载收藏夹: {} - {} 中所有未处理过的视频...",
|
let unhandled_videos_pages = video_list_model.unhandled_video_pages(connection).await?;
|
||||||
favorite_model.f_id, favorite_model.name
|
|
||||||
);
|
|
||||||
let unhandled_videos_pages = unhandled_videos_pages(&favorite_model, connection).await?;
|
|
||||||
// 对于视频,允许三个同时下载(视频内还有分页、不同分页还有多种下载任务)
|
// 对于视频,允许三个同时下载(视频内还有分页、不同分页还有多种下载任务)
|
||||||
let semaphore = Semaphore::new(3);
|
let semaphore = Semaphore::new(3);
|
||||||
let downloader = Downloader::new(bili_client.client.clone());
|
let downloader = Downloader::new(bili_client.client.clone());
|
||||||
@@ -197,10 +135,7 @@ pub async fn download_unprocessed_videos(
|
|||||||
if !models.is_empty() {
|
if !models.is_empty() {
|
||||||
update_videos_model(models, connection).await?;
|
update_videos_model(models, connection).await?;
|
||||||
}
|
}
|
||||||
info!(
|
video_list_model.log_download_video_end();
|
||||||
"下载收藏夹: {} - {} 中未处理过的视频完成",
|
|
||||||
favorite_model.f_id, favorite_model.name
|
|
||||||
);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6,5 +6,4 @@ publish = { workspace = true }
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
sea-orm = { workspace = true }
|
sea-orm = { workspace = true }
|
||||||
serde = { workspace = true }
|
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.15
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||||
|
#[sea_orm(table_name = "collection")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub s_id: i64,
|
||||||
|
pub m_id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub r#type: i32,
|
||||||
|
pub path: String,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
pub mod prelude;
|
pub mod prelude;
|
||||||
|
|
||||||
|
pub mod collection;
|
||||||
pub mod favorite;
|
pub mod favorite;
|
||||||
pub mod page;
|
pub mod page;
|
||||||
pub mod video;
|
pub mod video;
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ use sea_orm::entity::prelude::*;
|
|||||||
pub struct Model {
|
pub struct Model {
|
||||||
#[sea_orm(primary_key)]
|
#[sea_orm(primary_key)]
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub favorite_id: i32,
|
pub collection_id: Option<i32>,
|
||||||
|
pub favorite_id: Option<i32>,
|
||||||
pub upper_id: i64,
|
pub upper_id: i64,
|
||||||
pub upper_name: String,
|
pub upper_name: String,
|
||||||
pub upper_face: String,
|
pub upper_face: String,
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
pub use sea_orm_migration::prelude::*;
|
pub use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
mod m20240322_000001_create_table;
|
mod m20240322_000001_create_table;
|
||||||
|
mod m20240505_130850_add_collection;
|
||||||
|
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl MigratorTrait for Migrator {
|
impl MigratorTrait for Migrator {
|
||||||
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
||||||
vec![Box::new(m20240322_000001_create_table::Migration)]
|
vec![
|
||||||
|
Box::new(m20240322_000001_create_table::Migration),
|
||||||
|
Box::new(m20240505_130850_add_collection::Migration),
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
let db = manager.get_connection();
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(Collection::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Collection::Id)
|
||||||
|
.unsigned()
|
||||||
|
.not_null()
|
||||||
|
.auto_increment()
|
||||||
|
.primary_key(),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(Collection::SId).unsigned().not_null())
|
||||||
|
.col(ColumnDef::new(Collection::MId).unsigned().not_null())
|
||||||
|
.col(ColumnDef::new(Collection::Name).string().not_null())
|
||||||
|
.col(ColumnDef::new(Collection::Type).small_unsigned().not_null())
|
||||||
|
.col(ColumnDef::new(Collection::Path).string().not_null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Collection::CreatedAt)
|
||||||
|
.timestamp()
|
||||||
|
.default(Expr::current_timestamp())
|
||||||
|
.not_null(),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.table(Collection::Table)
|
||||||
|
.name("idx_collection_sid_mid_type")
|
||||||
|
.col(Collection::SId)
|
||||||
|
.col(Collection::MId)
|
||||||
|
.col(Collection::Type)
|
||||||
|
.unique()
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.drop_index(
|
||||||
|
Index::drop()
|
||||||
|
.table(Video::Table)
|
||||||
|
.name("idx_video_favorite_id_bvid")
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Video::Table)
|
||||||
|
.add_column(ColumnDef::new(Video::CollectionId).unsigned().null())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Video::Table)
|
||||||
|
.add_column(ColumnDef::new(Video::TempFavoriteId).unsigned().null())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
db.execute_unprepared("UPDATE video SET temp_favorite_id = favorite_id")
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Video::Table)
|
||||||
|
.drop_column(Video::FavoriteId)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Video::Table)
|
||||||
|
.rename_column(Video::TempFavoriteId, Video::FavoriteId)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.table(Video::Table)
|
||||||
|
.name("idx_video_cid_fid_bvid")
|
||||||
|
.col(Video::CollectionId)
|
||||||
|
.col(Video::FavoriteId)
|
||||||
|
.col(Video::Bvid)
|
||||||
|
.unique()
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
let db = manager.get_connection();
|
||||||
|
manager
|
||||||
|
.drop_index(
|
||||||
|
Index::drop()
|
||||||
|
.table(Video::Table)
|
||||||
|
.name("idx_video_cid_fid_bvid")
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
db.execute_unprepared("DELETE FROM video WHERE favorite_id IS NULL")
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Video::Table)
|
||||||
|
// 向存在记录的表中添加非空列时,必须提供默认值
|
||||||
|
.add_column(ColumnDef::new(Video::TempFavoriteId).unsigned().not_null().default(0))
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
db.execute_unprepared("UPDATE video SET temp_favorite_id = favorite_id")
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Video::Table)
|
||||||
|
.drop_column(Video::FavoriteId)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Video::Table)
|
||||||
|
.rename_column(Video::TempFavoriteId, Video::FavoriteId)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Video::Table)
|
||||||
|
.drop_column(Video::CollectionId)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.table(Video::Table)
|
||||||
|
.name("idx_video_favorite_id_bvid")
|
||||||
|
.col(Video::FavoriteId)
|
||||||
|
.col(Video::Bvid)
|
||||||
|
.unique()
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(Collection::Table).to_owned())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Collection {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
SId,
|
||||||
|
MId,
|
||||||
|
Name,
|
||||||
|
Type,
|
||||||
|
Path,
|
||||||
|
CreatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Video {
|
||||||
|
Table,
|
||||||
|
FavoriteId,
|
||||||
|
TempFavoriteId,
|
||||||
|
CollectionId,
|
||||||
|
Bvid,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user