Compare commits

...

9 Commits
1.0.7 ... 1.0.9

Author SHA1 Message Date
amtoaer
46d1810e7c chore: 处理成功的日志往外提一级 2023-12-04 01:50:31 +08:00
amtoaer
89e2567fef feat: tag 获取失败不影响主流程 2023-12-04 01:35:29 +08:00
amtoaer
38caf1f0d6 fix: 修复运行错误 2023-12-04 01:02:04 +08:00
amtoaer
6877171f4d fix: 修复参数错误 2023-12-04 00:54:04 +08:00
amtoaer
29d06a040b fix: 注册命令 2023-12-04 00:50:22 +08:00
amtoaer
ceec5d6780 feat: 尝试支持视频标签 2023-12-04 00:39:42 +08:00
amtoaer
650498d4a1 fix: 每天应仅检查一次 credential 2023-12-03 23:14:15 +08:00
amtoaer
96ff84391d doc: 修复图片路径过长的问题 2023-12-02 01:29:33 +08:00
amtoaer
44e8a2c97d doc: 加入对额外命令的描述,添加图片 2023-12-02 01:26:55 +08:00
9 changed files with 212 additions and 92 deletions

2
.gitignore vendored
View File

@@ -4,7 +4,7 @@ debug.py
videos
config.test.json
database.test.db*
example.json
example*.json
thumbs.test
config
data

View File

@@ -1,4 +1,6 @@
# bili-sync
![bili-sync](https://socialify.git.ci/amtoaer/bili-sync/image?description=1&font=KoHo&issues=1&language=1&logo=https%3A%2F%2Fs2.loli.net%2F2023%2F12%2F02%2F9EwT2yInOu1d3zm.png&name=1&owner=1&pattern=Signal&pulls=1&stargazers=1&theme=Light)
## 简介
为 NAS 用户编写的 BILIBILI 收藏夹同步工具,可方便导入 EMBY 等媒体库工具浏览。
@@ -82,6 +84,20 @@ services:
}
```
## 支持的额外命令
为满足需要,该应用包含几个单独的命令,可在程序目录下使用 `python entry.py ${command name}` 运行。
1. `once`
处理收藏夹,和一般定时任务触发时执行的操作完全相同,但仅运行一次。
2. `recheck`
将本地不存在的视频文件标记成未下载,下次定时任务触发时将一并下载。
3. `upper_thumb`
手动触发全量下载 up 主头像,为使用老版本时下载的没有 up 头像的视频添加头像。
## 路线图
- [x] 凭证认证

View File

@@ -4,8 +4,8 @@ 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 processor import download_content, process_video
from utils import aexists, amakedirs, aremove
async def recheck():
@@ -38,6 +38,7 @@ async def recheck():
async def upper_thumb():
"""将up主的头像批量写入数据库从不支持up主头像的版本升级上来后需要手动调用一次"""
makedir_tasks = []
other_tasks = []
for upper in await Upper.all():
@@ -60,3 +61,28 @@ async def upper_thumb():
await asyncio.gather(*makedir_tasks)
await asyncio.gather(*other_tasks)
logger.info("All done.")
async def refresh_tags():
"""刷新已存在的视频的标签,从不支持标签的版本升级上来后需要手动调用一次"""
items = await FavoriteItem.filter(
downloaded=True,
tags=None,
).prefetch_related("upper")
await asyncio.gather(
*[aremove(item.nfo_path) for item in items],
return_exceptions=True,
)
await asyncio.gather(
*[
process_video(
item,
process_poster=False,
process_video=False,
process_nfo=True,
process_upper=False,
)
for item in items
],
return_exceptions=True,
)

View File

@@ -4,7 +4,7 @@ import sys
import uvloop
from loguru import logger
from commands import recheck, upper_thumb
from commands import recheck, refresh_tags, upper_thumb
from models import init_model
from processor import cleanup, process
from settings import settings
@@ -18,6 +18,7 @@ async def entry() -> None:
("once", process),
("recheck", recheck),
("upper_thumb", upper_thumb),
("refresh_tags", refresh_tags),
]:
if any(command in _ for _ in sys.argv):
logger.info("Running {}...", command)

View 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";"""

View File

@@ -79,6 +79,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"
)

7
nfo.py
View File

@@ -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>

View File

@@ -81,16 +81,16 @@ 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
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.favorite_ids:
if favorite_id not in settings.path_mapper:
logger.warning(
@@ -156,88 +156,141 @@ async def process_favorite(favorite_id: int) -> None:
@concurrent_decorator(4)
async def process_video(fav_item: FavoriteItem) -> None:
async def process_video(
fav_item: FavoriteItem,
process_poster=True,
process_video=True,
process_nfo=True,
process_upper=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)
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,
if process_upper:
# 写入 up 主头像
if not all(
await asyncio.gather(
aexists(fav_item.upper.thumb_path),
aexists(fav_item.upper.meta_path),
)
],
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()
):
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,
)
if process_nfo:
if not await aexists(fav_item.nfo_path):
if fav_item.tags is None:
try:
fav_item.tags = [
_["tag_name"] for _ in await v.get_tags()
]
except Exception:
logger.exception(
"Failed to get tags of video {} {}",
fav_item.bvid,
fav_item.name,
)
# 写入 nfo
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,
)
if process_poster:
# 写入 poster
if not await aexists(fav_item.poster_path):
await download_content(fav_item.cover, fav_item.poster_path)
else:
logger.info(
"Poster of {} {} already exists, skipped.",
fav_item.bvid,
fav_item.name,
)
if process_video:
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
logger.info(
"{} {} processed successfully.", fav_item.bvid, fav_item.name
"{} {} processed successfully.",
fav_item.bvid,
fav_item.name,
)
except ResponseCodeException as e:
match e.code:
@@ -253,7 +306,6 @@ async def process_video(fav_item: FavoriteItem) -> None:
e.code,
)
return
await fav_item.save()
logger.error(
"Video {} {} is not available, marked as {}",
fav_item.bvid,
@@ -264,3 +316,5 @@ async def process_video(fav_item: FavoriteItem) -> None:
logger.exception(
"Failed to process video {} {}", fav_item.bvid, fav_item.name
)
finally:
await fav_item.save()

View File

@@ -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)