mirror of
https://github.com/amtoaer/bili-sync.git
synced 2026-05-07 16:53:40 +08:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1ada17f30 | ||
|
|
cb0ac7eb67 | ||
|
|
31efedbde9 | ||
|
|
3defb07325 | ||
|
|
e36f829e70 | ||
|
|
c20b579523 | ||
|
|
ceec222604 | ||
|
|
60ea7795ae | ||
|
|
6cbacbd127 | ||
|
|
8ea2fbe0f9 | ||
|
|
e3fded16ac | ||
|
|
961913c4fb | ||
|
|
fa20e5efee | ||
|
|
38fb0a4560 | ||
|
|
9e94e3b73e | ||
|
|
b955a9fe45 | ||
|
|
9d151b4731 | ||
|
|
1686c1a8df | ||
|
|
de6eaeb4a6 | ||
|
|
46d1810e7c | ||
|
|
89e2567fef | ||
|
|
38caf1f0d6 | ||
|
|
6877171f4d | ||
|
|
29d06a040b | ||
|
|
ceec5d6780 | ||
|
|
650498d4a1 | ||
|
|
96ff84391d | ||
|
|
44e8a2c97d |
27
.github/workflows/docker-image-debug.yml
vendored
27
.github/workflows/docker-image-debug.yml
vendored
@@ -12,18 +12,37 @@ jobs:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ secrets.DOCKERHUB_USERNAME }}/bili-sync
|
||||
tags: |
|
||||
type=raw,value=debug
|
||||
-
|
||||
name: Set Up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
-
|
||||
name: Set Up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
-
|
||||
name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
-
|
||||
-
|
||||
name: Build and push images
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
file: Dockerfile
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm64/v8
|
||||
push: true
|
||||
tags: |
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/bili-sync:debug
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha, scope=${{ github.workflow }}
|
||||
cache-to: type=gha, scope=${{ github.workflow }}
|
||||
29
.github/workflows/docker-image.yml
vendored
29
.github/workflows/docker-image.yml
vendored
@@ -12,22 +12,41 @@ jobs:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ secrets.DOCKERHUB_USERNAME }}/bili-sync
|
||||
tags: |
|
||||
type=raw,value=${{ github.ref_name }}
|
||||
type=raw,value=latest
|
||||
-
|
||||
name: Set Up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
-
|
||||
name: Set Up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
-
|
||||
name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
-
|
||||
-
|
||||
name: Build and push images
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
file: Dockerfile
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm64/v8
|
||||
push: true
|
||||
tags: |
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/bili-sync:${{ github.ref_name }}
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/bili-sync:latest
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha, scope=${{ github.workflow }}
|
||||
cache-to: type=gha, scope=${{ github.workflow }}
|
||||
-
|
||||
name: Update DockerHub description
|
||||
uses: peter-evans/dockerhub-description@v3
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,7 +4,7 @@ debug.py
|
||||
videos
|
||||
config.test.json
|
||||
database.test.db*
|
||||
example.json
|
||||
example*.json
|
||||
thumbs.test
|
||||
config
|
||||
data
|
||||
|
||||
34
Dockerfile
34
Dockerfile
@@ -1,22 +1,30 @@
|
||||
FROM python:3.11.6-alpine3.18 AS base
|
||||
FROM python:3.11.7-alpine3.19
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV BILI_IN_DOCKER=true
|
||||
ENV LANG=zh_CN.UTF-8 \
|
||||
TZ=Asia/Shanghai \
|
||||
BILI_IN_DOCKER=true
|
||||
|
||||
RUN apk add --no-cache ffmpeg tini \
|
||||
&& apk add --no-cache --virtual .build-deps \
|
||||
gcc \
|
||||
musl-dev \
|
||||
libffi-dev \
|
||||
openssl-dev \
|
||||
&& pip install poetry
|
||||
|
||||
COPY poetry.lock pyproject.toml ./
|
||||
|
||||
RUN apk add ffmpeg \
|
||||
&& apk add --no-cache --virtual .build-deps \
|
||||
gcc \
|
||||
musl-dev \
|
||||
libffi-dev \
|
||||
openssl-dev \
|
||||
&& pip install poetry \
|
||||
&& poetry config virtualenvs.create false \
|
||||
&& poetry install --no-dev --no-interaction --no-ansi \
|
||||
&& apk del .build-deps
|
||||
RUN poetry config virtualenvs.create false \
|
||||
&& poetry install --only main --no-root \
|
||||
&& apk del .build-deps \
|
||||
&& rm -rf \
|
||||
/root/.cache \
|
||||
/tmp/*
|
||||
|
||||
COPY . .
|
||||
|
||||
ENTRYPOINT [ "python", "entry.py" ]
|
||||
ENTRYPOINT [ "tini", "python", "entry.py" ]
|
||||
|
||||
VOLUME [ "/app/config", "/app/data", "/app/thumb", "/Videos/Bilibilis" ]
|
||||
10
Makefile
10
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: install fmt start-daemon start-once
|
||||
.PHONY: install fmt start-daemon start-once db-init db-migrate db-upgrade sync-conf
|
||||
|
||||
install:
|
||||
@echo "Installing dependencies..."
|
||||
@@ -22,4 +22,10 @@ db-migrate:
|
||||
@poetry run aerich migrate
|
||||
|
||||
db-upgrade:
|
||||
@poetry run aerich upgrade
|
||||
@poetry run aerich upgrade
|
||||
|
||||
sync-conf:
|
||||
@echo "Syncing config..."
|
||||
@cp ${CONFIG_SRC} ./config/
|
||||
@cp ${DB_SRC} ./data/
|
||||
@echo "Done."
|
||||
56
README.md
56
README.md
@@ -1,4 +1,6 @@
|
||||
# bili-sync
|
||||

|
||||
|
||||
## 简介
|
||||
|
||||
为 NAS 用户编写的 BILIBILI 收藏夹同步工具,可方便导入 EMBY 等媒体库工具浏览。
|
||||
|
||||
@@ -11,15 +13,23 @@
|
||||
|
||||
## 工作截图
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
## 配置文件
|
||||
|
||||
对于配置文件的前五项,请参考[凭据获取流程](https://nemo2011.github.io/bilibili-api/#/get-credential)。
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class SubtitleConfig(DataClassJsonMixin):
|
||||
font_name: str = "微软雅黑,黑体" # 字体
|
||||
font_size: float = 40 # 字号
|
||||
alpha: float = 0.8 # 透明度
|
||||
fly_time: float = 5 # 滚动弹幕持续时间
|
||||
static_time: float = 10 # 静态弹幕持续时间
|
||||
|
||||
class Config(DataClassJsonMixin):
|
||||
sessdata: str = ""
|
||||
bili_jct: str = ""
|
||||
@@ -27,8 +37,8 @@ class Config(DataClassJsonMixin):
|
||||
dedeuserid: str = ""
|
||||
ac_time_value: str = ""
|
||||
interval: int = 20 # 任务执行的间隔时间
|
||||
favorite_ids: list[int] = field(default_factory=list) # 收藏夹的 id
|
||||
path_mapper: dict[int, str] = field(default_factory=dict) # 收藏夹的 id 到存储目录的映射
|
||||
subtitle: SubtitleConfig = field(default_factory=SubtitleConfig) # 字幕相关设置
|
||||
```
|
||||
|
||||
程序默认会将配置文件存储于 `${程序路径}/config/config.json`,数据库文件存储于 `${程序路径}/data/data.db`,如果发现不存在则新建并写入初始配置。
|
||||
@@ -73,15 +83,47 @@ services:
|
||||
"dedeuserid": "xxxxxxxxxxxxxxxxxx",
|
||||
"ac_time_value": "xxxxxxxxxxxxxxxxxx",
|
||||
"interval": 20,
|
||||
"favorite_ids": [
|
||||
711322958
|
||||
],
|
||||
"path_mapper": {
|
||||
"711322958": "/Videos/Bilibilis/Bilibili-711322958/"
|
||||
},
|
||||
"subtitle": {
|
||||
"font_name": "微软雅黑,黑体",
|
||||
"font_size": 40.0,
|
||||
"alpha": 0.8,
|
||||
"fly_time": 5.0,
|
||||
"static_time": 10.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 支持的额外命令
|
||||
|
||||
为满足需要,该应用包含几个单独的命令,可在程序目录下使用 `python entry.py ${command name}` 运行。
|
||||
|
||||
1. `once`
|
||||
|
||||
处理收藏夹,和一般定时任务触发时执行的操作完全相同,但仅运行一次。
|
||||
2. `recheck`
|
||||
|
||||
将本地不存在的视频文件标记成未下载,下次定时任务触发时将一并下载。
|
||||
3. `refresh_refresh_poster`
|
||||
|
||||
更新本地视频的封面。
|
||||
3. `refresh_upper`
|
||||
|
||||
更新本地up的头像和元数据。
|
||||
3. `refresh_nfo`
|
||||
|
||||
更新本地视频的元数据。(如标签、标题等信息)
|
||||
3. `refresh_video`
|
||||
|
||||
更新本地的视频源文件。
|
||||
3. `refresh_subtitle`
|
||||
|
||||
更新本地的弹幕文件。
|
||||
|
||||
**对于以 refresh 开头的命令,均支持 --force 参数,如果有 --force 参数,将全量覆盖对应内容,否则默认仅更新缺失的部分。**
|
||||
|
||||
## 路线图
|
||||
|
||||
- [x] 凭证认证
|
||||
|
||||
89
commands.py
89
commands.py
@@ -1,11 +1,14 @@
|
||||
import asyncio
|
||||
import functools
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from constants import MediaStatus, MediaType
|
||||
from models import FavoriteItem, Upper
|
||||
from processor import download_content
|
||||
from utils import aexists, amakedirs
|
||||
from models import FavoriteItem
|
||||
from processor import process_favorite_item
|
||||
from utils import aexists, aremove
|
||||
|
||||
|
||||
async def recheck():
|
||||
@@ -37,26 +40,62 @@ async def recheck():
|
||||
logger.info("Database updated.")
|
||||
|
||||
|
||||
async def upper_thumb():
|
||||
makedir_tasks = []
|
||||
other_tasks = []
|
||||
for upper in await Upper.all():
|
||||
if all(
|
||||
await asyncio.gather(
|
||||
aexists(upper.thumb_path), aexists(upper.meta_path)
|
||||
)
|
||||
):
|
||||
logger.info(
|
||||
"Upper {} {} already exists, skipped.", upper.mid, upper.name
|
||||
)
|
||||
makedir_tasks.append(amakedirs(upper.thumb_path.parent, exist_ok=True))
|
||||
logger.info("Saving metadata for upper {} {}...", upper.mid, upper.name)
|
||||
other_tasks.extend(
|
||||
[
|
||||
upper.save_metadata(),
|
||||
download_content(upper.thumb, upper.thumb_path),
|
||||
]
|
||||
async def _refresh_favorite_item_info(
|
||||
path_getter: Callable[[FavoriteItem], list[Path]],
|
||||
process_poster: bool = False,
|
||||
process_video: bool = False,
|
||||
process_nfo: bool = False,
|
||||
process_upper: bool = False,
|
||||
process_subtitle: bool = False,
|
||||
force: bool = False,
|
||||
):
|
||||
items = await FavoriteItem.filter(downloaded=True).prefetch_related("upper")
|
||||
if force:
|
||||
# 如果强制刷新,那么就先把现存的所有内容删除
|
||||
await asyncio.gather(
|
||||
*[aremove(path) for item in items for path in path_getter(item)],
|
||||
return_exceptions=True,
|
||||
)
|
||||
await asyncio.gather(*makedir_tasks)
|
||||
await asyncio.gather(*other_tasks)
|
||||
logger.info("All done.")
|
||||
await asyncio.gather(
|
||||
*[
|
||||
process_favorite_item(
|
||||
item,
|
||||
process_poster=process_poster,
|
||||
process_video=process_video,
|
||||
process_nfo=process_nfo,
|
||||
process_upper=process_upper,
|
||||
process_subtitle=process_subtitle,
|
||||
)
|
||||
for item in items
|
||||
],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
|
||||
refresh_nfo = functools.partial(
|
||||
_refresh_favorite_item_info, lambda item: [item.nfo_path], process_nfo=True
|
||||
)
|
||||
|
||||
refresh_poster = functools.partial(
|
||||
_refresh_favorite_item_info,
|
||||
lambda item: [item.poster_path],
|
||||
process_poster=True,
|
||||
)
|
||||
|
||||
refresh_video = functools.partial(
|
||||
_refresh_favorite_item_info,
|
||||
lambda item: [item.video_path],
|
||||
process_video=True,
|
||||
)
|
||||
|
||||
refresh_upper = functools.partial(
|
||||
_refresh_favorite_item_info,
|
||||
lambda item: item.upper_path,
|
||||
process_upper=True,
|
||||
)
|
||||
|
||||
refresh_subtitle = functools.partial(
|
||||
_refresh_favorite_item_info,
|
||||
lambda item: [item.subtitle_path],
|
||||
process_subtitle=True,
|
||||
)
|
||||
|
||||
25
entry.py
25
entry.py
@@ -4,7 +4,14 @@ import sys
|
||||
import uvloop
|
||||
from loguru import logger
|
||||
|
||||
from commands import recheck, upper_thumb
|
||||
from commands import (
|
||||
recheck,
|
||||
refresh_nfo,
|
||||
refresh_poster,
|
||||
refresh_subtitle,
|
||||
refresh_upper,
|
||||
refresh_video,
|
||||
)
|
||||
from models import init_model
|
||||
from processor import cleanup, process
|
||||
from settings import settings
|
||||
@@ -14,14 +21,22 @@ asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
||||
|
||||
async def entry() -> None:
|
||||
await init_model()
|
||||
for command, func in [
|
||||
force = any("force" in _ for _ in sys.argv)
|
||||
for command, func in (
|
||||
("once", process),
|
||||
("recheck", recheck),
|
||||
("upper_thumb", upper_thumb),
|
||||
]:
|
||||
("refresh_poster", refresh_poster),
|
||||
("refresh_upper", refresh_upper),
|
||||
("refresh_nfo", refresh_nfo),
|
||||
("refresh_video", refresh_video),
|
||||
("refresh_subtitle", refresh_subtitle),
|
||||
):
|
||||
if any(command in _ for _ in sys.argv):
|
||||
logger.info("Running {}...", command)
|
||||
await func()
|
||||
if command.startswith("refresh"):
|
||||
await func(force=force)
|
||||
else:
|
||||
await func()
|
||||
return
|
||||
logger.info("Running daemon...")
|
||||
while True:
|
||||
|
||||
11
migrations/models/2_20231204003326_update.py
Normal file
11
migrations/models/2_20231204003326_update.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
ALTER TABLE "favoriteitem" ADD "tags" JSON;"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
ALTER TABLE "favoriteitem" DROP COLUMN "tags";"""
|
||||
14
migrations/models/3_20240104221037_update.py
Normal file
14
migrations/models/3_20240104221037_update.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
CREATE TABLE IF NOT EXISTS "program" (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
"version" VARCHAR(20) NOT NULL
|
||||
);"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
DROP TABLE IF EXISTS "program";"""
|
||||
31
models.py
31
models.py
@@ -14,6 +14,7 @@ from constants import (
|
||||
)
|
||||
from settings import settings
|
||||
from utils import aopen
|
||||
from version import VERSION
|
||||
|
||||
|
||||
class FavoriteList(Model):
|
||||
@@ -79,6 +80,7 @@ class FavoriteItem(Model):
|
||||
bvid = fields.CharField(max_length=255)
|
||||
desc = fields.TextField()
|
||||
cover = fields.TextField()
|
||||
tags = fields.JSONField(null=True)
|
||||
favorite_list = fields.ForeignKeyField(
|
||||
"models.FavoriteList", related_name="items"
|
||||
)
|
||||
@@ -132,6 +134,25 @@ class FavoriteItem(Model):
|
||||
/ f"{self.bvid}-poster.jpg"
|
||||
)
|
||||
|
||||
@property
|
||||
def upper_path(self) -> list[Path]:
|
||||
return [
|
||||
self.upper.thumb_path,
|
||||
self.upper.meta_path,
|
||||
]
|
||||
|
||||
@property
|
||||
def subtitle_path(self) -> Path:
|
||||
return (
|
||||
Path(settings.path_mapper[self.favorite_list_id])
|
||||
/ f"{self.bvid}.zh-CN.default.ass"
|
||||
)
|
||||
|
||||
|
||||
class Program(Model):
|
||||
id = fields.IntField(pk=True)
|
||||
version = fields.CharField(max_length=20)
|
||||
|
||||
|
||||
async def init_model() -> None:
|
||||
await Tortoise.init(config=TORTOISE_ORM)
|
||||
@@ -142,3 +163,13 @@ async def init_model() -> None:
|
||||
)
|
||||
process = await create_subprocess_exec(*migrate_commands)
|
||||
await process.communicate()
|
||||
program, created = await Program.get_or_create(
|
||||
defaults={
|
||||
"version": VERSION,
|
||||
}
|
||||
)
|
||||
if created or program.version != VERSION:
|
||||
# 把新版本的迁移逻辑写在这里
|
||||
pass
|
||||
program.version = VERSION
|
||||
await program.save()
|
||||
|
||||
7
nfo.py
7
nfo.py
@@ -25,6 +25,7 @@ class Actor:
|
||||
class EpisodeInfo:
|
||||
title: str
|
||||
plot: str
|
||||
tags: list[str]
|
||||
actor: list[Actor]
|
||||
bvid: str
|
||||
aired: datetime.datetime
|
||||
@@ -35,6 +36,11 @@ class EpisodeInfo:
|
||||
|
||||
def to_xml(self) -> str:
|
||||
actor = "\n".join(_.to_xml() for _ in self.actor)
|
||||
tags = (
|
||||
"\n".join(f" <genre>{_}</genre>" for _ in self.tags)
|
||||
if isinstance(self.tags, list)
|
||||
else ""
|
||||
)
|
||||
return f"""
|
||||
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<episodedetails>
|
||||
@@ -43,6 +49,7 @@ class EpisodeInfo:
|
||||
<title>{self.title}</title>
|
||||
{actor}
|
||||
<year>{self.aired.year}</year>
|
||||
{tags}
|
||||
<uniqueid type="bilibili">{self.bvid}</uniqueid>
|
||||
<aired>{self.aired.strftime("%Y-%m-%d")}</aired>
|
||||
</episodedetails>
|
||||
|
||||
949
poetry.lock
generated
949
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
343
processor.py
343
processor.py
@@ -3,10 +3,10 @@ import datetime
|
||||
from asyncio import Semaphore, create_subprocess_exec
|
||||
from asyncio.subprocess import DEVNULL
|
||||
|
||||
from bilibili_api import favorite_list, video
|
||||
from bilibili_api import ass, favorite_list, video
|
||||
from bilibili_api.exceptions import ResponseCodeException
|
||||
from loguru import logger
|
||||
from tortoise import Tortoise
|
||||
from tortoise.connection import connections
|
||||
|
||||
from constants import FFMPEG_COMMAND, MediaStatus, MediaType
|
||||
from credential import credential
|
||||
@@ -20,7 +20,7 @@ anchor = datetime.date.today()
|
||||
|
||||
async def cleanup() -> None:
|
||||
await client.aclose()
|
||||
await Tortoise.close_connections()
|
||||
await connections.close_all()
|
||||
|
||||
|
||||
def concurrent_decorator(concurrency: int) -> callable:
|
||||
@@ -81,22 +81,17 @@ async def manage_model(medias: list[dict], fav_list: FavoriteList) -> None:
|
||||
|
||||
async def process() -> None:
|
||||
global anchor
|
||||
if (
|
||||
today := datetime.date.today()
|
||||
) > anchor and await credential.check_refresh():
|
||||
try:
|
||||
await credential.refresh()
|
||||
anchor = today
|
||||
logger.info("Credential refreshed.")
|
||||
except Exception:
|
||||
logger.exception("Failed to refresh credential.")
|
||||
return
|
||||
for favorite_id in settings.favorite_ids:
|
||||
if favorite_id not in settings.path_mapper:
|
||||
logger.warning(
|
||||
f"Favorite {favorite_id} not in path mapper, ignored."
|
||||
)
|
||||
continue
|
||||
if (today := datetime.date.today()) > anchor:
|
||||
anchor = today
|
||||
logger.info("Check credential.")
|
||||
if await credential.check_refresh():
|
||||
try:
|
||||
await credential.refresh()
|
||||
logger.info("Credential refreshed.")
|
||||
except Exception:
|
||||
logger.exception("Failed to refresh credential.")
|
||||
return
|
||||
for favorite_id in settings.path_mapper:
|
||||
await process_favorite(favorite_id)
|
||||
|
||||
|
||||
@@ -149,118 +144,226 @@ async def process_favorite(favorite_id: int) -> None:
|
||||
downloaded=False,
|
||||
).prefetch_related("upper")
|
||||
await asyncio.gather(
|
||||
*[process_video(item) for item in all_unprocessed_items],
|
||||
*[process_favorite_item(item) for item in all_unprocessed_items],
|
||||
return_exceptions=True,
|
||||
)
|
||||
logger.info("Favorite {} {} processed successfully.", favorite_id, title)
|
||||
|
||||
|
||||
@concurrent_decorator(4)
|
||||
async def process_video(fav_item: FavoriteItem) -> None:
|
||||
async def process_favorite_item(
|
||||
fav_item: FavoriteItem,
|
||||
process_poster=True,
|
||||
process_video=True,
|
||||
process_nfo=True,
|
||||
process_upper=True,
|
||||
process_subtitle=True,
|
||||
) -> None:
|
||||
logger.info("Start to process video {} {}", fav_item.bvid, fav_item.name)
|
||||
if fav_item.type != MediaType.VIDEO:
|
||||
logger.warning("Media {} is not a video, skipped.", fav_item.name)
|
||||
return
|
||||
v = video.Video(fav_item.bvid, credential=credential)
|
||||
# 如果没有获取过 tags,那么尝试获取一下
|
||||
try:
|
||||
if await aexists(fav_item.video_path):
|
||||
fav_item.downloaded = True
|
||||
await fav_item.save()
|
||||
logger.info(
|
||||
"{} {} already exists, skipped.", fav_item.bvid, fav_item.name
|
||||
)
|
||||
return
|
||||
# 写入 up 主头像
|
||||
if not all(
|
||||
await asyncio.gather(
|
||||
aexists(fav_item.upper.thumb_path),
|
||||
aexists(fav_item.upper.meta_path),
|
||||
)
|
||||
):
|
||||
await amakedirs(fav_item.upper.thumb_path.parent, exist_ok=True)
|
||||
await fav_item.upper.save_metadata()
|
||||
await download_content(
|
||||
fav_item.upper.thumb, fav_item.upper.thumb_path
|
||||
)
|
||||
# 写入 nfo
|
||||
await EpisodeInfo(
|
||||
title=fav_item.name,
|
||||
plot=fav_item.desc,
|
||||
actor=[
|
||||
Actor(
|
||||
name=fav_item.upper.mid,
|
||||
role=fav_item.upper.name,
|
||||
)
|
||||
],
|
||||
bvid=fav_item.bvid,
|
||||
aired=fav_item.ctime,
|
||||
).write_nfo(fav_item.nfo_path)
|
||||
# 写入 poster
|
||||
await download_content(fav_item.cover, fav_item.poster_path)
|
||||
# 开始处理视频内容
|
||||
v = video.Video(fav_item.bvid, credential=credential)
|
||||
detector = video.VideoDownloadURLDataDetecter(
|
||||
await v.get_download_url(page_index=0)
|
||||
)
|
||||
streams = detector.detect_best_streams()
|
||||
if detector.check_flv_stream():
|
||||
await download_content(streams[0].url, fav_item.tmp_video_path)
|
||||
process = await create_subprocess_exec(
|
||||
FFMPEG_COMMAND,
|
||||
"-i",
|
||||
str(fav_item.tmp_video_path),
|
||||
str(fav_item.video_path),
|
||||
stdout=DEVNULL,
|
||||
stderr=DEVNULL,
|
||||
)
|
||||
await process.communicate()
|
||||
fav_item.tmp_video_path.unlink()
|
||||
else:
|
||||
await asyncio.gather(
|
||||
download_content(streams[0].url, fav_item.tmp_video_path),
|
||||
download_content(streams[1].url, fav_item.tmp_audio_path),
|
||||
)
|
||||
process = await create_subprocess_exec(
|
||||
FFMPEG_COMMAND,
|
||||
"-i",
|
||||
str(fav_item.tmp_video_path),
|
||||
"-i",
|
||||
str(fav_item.tmp_audio_path),
|
||||
"-c",
|
||||
"copy",
|
||||
str(fav_item.video_path),
|
||||
stdout=DEVNULL,
|
||||
stderr=DEVNULL,
|
||||
)
|
||||
await process.communicate()
|
||||
fav_item.tmp_video_path.unlink()
|
||||
fav_item.tmp_audio_path.unlink()
|
||||
fav_item.downloaded = True
|
||||
await fav_item.save()
|
||||
logger.info(
|
||||
"{} {} processed successfully.", fav_item.bvid, fav_item.name
|
||||
)
|
||||
except ResponseCodeException as e:
|
||||
match e.code:
|
||||
case 62002:
|
||||
fav_item.status = MediaStatus.INVISIBLE
|
||||
case -404:
|
||||
fav_item.status = MediaStatus.DELETED
|
||||
case _:
|
||||
logger.exception(
|
||||
"Failed to process video {} {}, error_code: {}",
|
||||
fav_item.bvid,
|
||||
fav_item.name,
|
||||
e.code,
|
||||
)
|
||||
return
|
||||
await fav_item.save()
|
||||
logger.error(
|
||||
"Video {} {} is not available, marked as {}",
|
||||
fav_item.bvid,
|
||||
fav_item.name,
|
||||
fav_item.status.text,
|
||||
)
|
||||
if fav_item.tags is None:
|
||||
fav_item.tags = [_["tag_name"] for _ in await v.get_tags()]
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to process video {} {}", fav_item.bvid, fav_item.name
|
||||
"Failed to get tags of video {} {}",
|
||||
fav_item.bvid,
|
||||
fav_item.name,
|
||||
)
|
||||
|
||||
if process_upper:
|
||||
try:
|
||||
if not all(
|
||||
await asyncio.gather(
|
||||
aexists(fav_item.upper.thumb_path),
|
||||
aexists(fav_item.upper.meta_path),
|
||||
)
|
||||
):
|
||||
await amakedirs(fav_item.upper.thumb_path.parent, exist_ok=True)
|
||||
await asyncio.gather(
|
||||
fav_item.upper.save_metadata(),
|
||||
download_content(
|
||||
fav_item.upper.thumb, fav_item.upper.thumb_path
|
||||
),
|
||||
return_exceptions=True,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Upper {} {} already exists, skipped.",
|
||||
fav_item.upper.mid,
|
||||
fav_item.upper.name,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to process upper {} {}",
|
||||
fav_item.upper.mid,
|
||||
fav_item.upper.name,
|
||||
)
|
||||
|
||||
if process_nfo:
|
||||
try:
|
||||
if not await aexists(fav_item.nfo_path):
|
||||
await EpisodeInfo(
|
||||
title=fav_item.name,
|
||||
plot=fav_item.desc,
|
||||
actor=[
|
||||
Actor(
|
||||
name=fav_item.upper.mid,
|
||||
role=fav_item.upper.name,
|
||||
)
|
||||
],
|
||||
tags=fav_item.tags,
|
||||
bvid=fav_item.bvid,
|
||||
aired=fav_item.ctime,
|
||||
).write_nfo(fav_item.nfo_path)
|
||||
else:
|
||||
logger.info(
|
||||
"NFO of {} {} already exists, skipped.",
|
||||
fav_item.bvid,
|
||||
fav_item.name,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to process nfo of video {} {}",
|
||||
fav_item.bvid,
|
||||
fav_item.name,
|
||||
)
|
||||
|
||||
if process_poster:
|
||||
try:
|
||||
if not await aexists(fav_item.poster_path):
|
||||
try:
|
||||
await download_content(fav_item.cover, fav_item.poster_path)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to download poster of video {} {}",
|
||||
fav_item.bvid,
|
||||
fav_item.name,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Poster of {} {} already exists, skipped.",
|
||||
fav_item.bvid,
|
||||
fav_item.name,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to process poster of video {} {}",
|
||||
fav_item.bvid,
|
||||
fav_item.name,
|
||||
)
|
||||
|
||||
if process_subtitle:
|
||||
try:
|
||||
if not await aexists(fav_item.subtitle_path):
|
||||
await ass.make_ass_file_danmakus_protobuf(
|
||||
v,
|
||||
0,
|
||||
str(fav_item.subtitle_path.resolve()),
|
||||
credential=credential,
|
||||
font_name=settings.subtitle.font_name,
|
||||
font_size=settings.subtitle.font_size,
|
||||
alpha=settings.subtitle.alpha,
|
||||
fly_time=settings.subtitle.fly_time,
|
||||
static_time=settings.subtitle.static_time,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Subtitle of {} {} already exists, skipped.",
|
||||
fav_item.bvid,
|
||||
fav_item.name,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to process subtitle of video {} {}",
|
||||
fav_item.bvid,
|
||||
fav_item.name,
|
||||
)
|
||||
if process_video:
|
||||
try:
|
||||
if await aexists(fav_item.video_path):
|
||||
fav_item.downloaded = True
|
||||
logger.info(
|
||||
"Video {} {} already exists, skipped.",
|
||||
fav_item.bvid,
|
||||
fav_item.name,
|
||||
)
|
||||
else:
|
||||
# 开始处理视频内容
|
||||
detector = video.VideoDownloadURLDataDetecter(
|
||||
await v.get_download_url(page_index=0)
|
||||
)
|
||||
streams = detector.detect_best_streams()
|
||||
if detector.check_flv_stream():
|
||||
await download_content(
|
||||
streams[0].url, fav_item.tmp_video_path
|
||||
)
|
||||
process = await create_subprocess_exec(
|
||||
FFMPEG_COMMAND,
|
||||
"-i",
|
||||
str(fav_item.tmp_video_path),
|
||||
str(fav_item.video_path),
|
||||
stdout=DEVNULL,
|
||||
stderr=DEVNULL,
|
||||
)
|
||||
await process.communicate()
|
||||
fav_item.tmp_video_path.unlink()
|
||||
else:
|
||||
await asyncio.gather(
|
||||
download_content(
|
||||
streams[0].url, fav_item.tmp_video_path
|
||||
),
|
||||
download_content(
|
||||
streams[1].url, fav_item.tmp_audio_path
|
||||
),
|
||||
)
|
||||
process = await create_subprocess_exec(
|
||||
FFMPEG_COMMAND,
|
||||
"-i",
|
||||
str(fav_item.tmp_video_path),
|
||||
"-i",
|
||||
str(fav_item.tmp_audio_path),
|
||||
"-c",
|
||||
"copy",
|
||||
str(fav_item.video_path),
|
||||
stdout=DEVNULL,
|
||||
stderr=DEVNULL,
|
||||
)
|
||||
await process.communicate()
|
||||
fav_item.tmp_video_path.unlink()
|
||||
fav_item.tmp_audio_path.unlink()
|
||||
fav_item.downloaded = True
|
||||
except ResponseCodeException as e:
|
||||
match e.code:
|
||||
case 62002:
|
||||
fav_item.status = MediaStatus.INVISIBLE
|
||||
case -404:
|
||||
fav_item.status = MediaStatus.DELETED
|
||||
case _:
|
||||
logger.exception(
|
||||
"Failed to process video {} {}, error_code: {}",
|
||||
fav_item.bvid,
|
||||
fav_item.name,
|
||||
e.code,
|
||||
)
|
||||
if fav_item.status != MediaStatus.NORMAL:
|
||||
logger.error(
|
||||
"Video {} {} is not available, marked as {}",
|
||||
fav_item.bvid,
|
||||
fav_item.name,
|
||||
fav_item.status.text,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to process video {} {}", fav_item.bvid, fav_item.name
|
||||
)
|
||||
await fav_item.save()
|
||||
logger.info(
|
||||
"{} {} is processed successfully.",
|
||||
fav_item.bvid,
|
||||
fav_item.name,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "bili-sync"
|
||||
version = "1.0.1"
|
||||
version = "1.1.3"
|
||||
description = ""
|
||||
authors = ["amtoaer <amtoaer@gmail.com>"]
|
||||
license = "GPL-3.0"
|
||||
@@ -8,18 +8,19 @@ readme = "README.md"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.11"
|
||||
bilibili-api-python = { git = "https://github.com/amtoaer/bilibili-api.git", rev = "dev" }
|
||||
dataclasses-json = "0.6.2"
|
||||
tortoise-orm = "0.20.0"
|
||||
loguru = "0.7.2"
|
||||
uvloop = "0.19.0"
|
||||
aiofiles = "23.2.1"
|
||||
aerich = "0.7.2"
|
||||
aiofiles = "23.2.1"
|
||||
bilibili-api-python = {git = "https://github.com/amtoaer/bilibili-api", rev = "dev"}
|
||||
dataclasses-json = "0.6.2"
|
||||
loguru = "0.7.2"
|
||||
tortoise-orm = "0.20.0"
|
||||
uvloop = "0.19.0"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
black = "23.11.0"
|
||||
ruff = "0.1.6"
|
||||
bump-my-version = "0.15.4"
|
||||
ipython = "8.17.2"
|
||||
ruff = "0.1.6"
|
||||
|
||||
[tool.black]
|
||||
line-length = 80
|
||||
@@ -60,6 +61,22 @@ tortoise_orm = "constants.TORTOISE_ORM"
|
||||
location = "./migrations"
|
||||
src_folder = "./."
|
||||
|
||||
[tool.bumpversion]
|
||||
commit = true
|
||||
message = "chore: bump version from {current_version} to {new_version}"
|
||||
tag = true
|
||||
tag_name = "{new_version}"
|
||||
tag_message = ""
|
||||
current_version = "1.1.3"
|
||||
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
||||
|
||||
[[tool.bumpversion.files]]
|
||||
filename = "version.py"
|
||||
|
||||
[[tool.bumpversion.files]]
|
||||
filename = "pyproject.toml"
|
||||
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
17
settings.py
17
settings.py
@@ -2,21 +2,34 @@ from dataclasses import dataclass, field, fields
|
||||
from pathlib import Path
|
||||
from typing import Self
|
||||
|
||||
from dataclasses_json import DataClassJsonMixin
|
||||
from dataclasses_json import DataClassJsonMixin, Undefined
|
||||
|
||||
from constants import DEFAULT_CONFIG_PATH
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubtitleConfig(DataClassJsonMixin):
|
||||
dataclass_json_config = {"undefined": Undefined.EXCLUDE}
|
||||
|
||||
font_name: str = "微软雅黑,黑体" # 字体
|
||||
font_size: float = 40 # 字号
|
||||
alpha: float = 0.8 # 透明度
|
||||
fly_time: float = 5 # 滚动弹幕持续时间
|
||||
static_time: float = 10 # 静态弹幕持续时间
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config(DataClassJsonMixin):
|
||||
dataclass_json_config = {"undefined": Undefined.EXCLUDE}
|
||||
|
||||
sessdata: str = ""
|
||||
bili_jct: str = ""
|
||||
buvid3: str = ""
|
||||
dedeuserid: str = ""
|
||||
ac_time_value: str = ""
|
||||
interval: int = 20
|
||||
favorite_ids: list[int] = field(default_factory=list)
|
||||
path_mapper: dict[int, str] = field(default_factory=dict)
|
||||
subtitle: SubtitleConfig = field(default_factory=SubtitleConfig)
|
||||
|
||||
def validate(self) -> Self:
|
||||
"""所有值必须被设置"""
|
||||
|
||||
6
utils.py
6
utils.py
@@ -3,7 +3,7 @@ from pathlib import Path
|
||||
import aiofiles
|
||||
import httpx
|
||||
from aiofiles.base import AiofilesContextManager
|
||||
from aiofiles.os import makedirs
|
||||
from aiofiles.os import makedirs, remove
|
||||
from aiofiles.ospath import exists
|
||||
from aiofiles.threadpool.text import AsyncTextIOWrapper
|
||||
from bilibili_api import HEADERS
|
||||
@@ -31,3 +31,7 @@ def aopen(
|
||||
path: Path, mode: str = "r", **kwargs
|
||||
) -> AiofilesContextManager[None, None, AsyncTextIOWrapper]:
|
||||
return aiofiles.open(path, mode, **kwargs)
|
||||
|
||||
|
||||
async def aremove(path: Path) -> None:
|
||||
await remove(path)
|
||||
|
||||
1
version.py
Normal file
1
version.py
Normal file
@@ -0,0 +1 @@
|
||||
VERSION = "1.1.3"
|
||||
Reference in New Issue
Block a user