Compare commits

...

12 Commits
1.0.2 ... 1.0.7

Author SHA1 Message Date
amtoaer
c3bfb3c2e5 doc: 已解决 up 头像问题,更新 README 2023-12-02 01:12:59 +08:00
amtoaer
ec91cbf3ed fix: 继续修复字段错误 2023-12-02 00:51:21 +08:00
amtoaer
f174a3b898 fix: 修复字段错误,凭据刷新从第二天开始 2023-12-02 00:46:21 +08:00
amtoaer
c8fca7fcca style: 格式化代码 2023-12-02 00:40:55 +08:00
amtoaer
6ef25d6409 fix: 修复类型错误,加入部分日志 2023-12-02 00:40:35 +08:00
amtoaer
f10fc9dd97 fix: 修复字段取值错误 2023-12-02 00:30:56 +08:00
amtoaer
d21f14d851 ci: 推送 main 时构建 debug 镜像 2023-12-02 00:30:47 +08:00
amtoaer
012b3f9f31 fix: 尝试修复 emby 头像路径,异步化所有文件操作 2023-12-02 00:26:19 +08:00
amtoaer
bbde9d6ba6 fix: 修复判断 2023-11-30 18:12:15 +08:00
amtoaer
e040ab2d75 chore: 尝试降低更新凭据频率,防止风控 2023-11-30 17:50:36 +08:00
amtoaer
ad977e41d4 fix: 修复异步函数没有 await 的问题 2023-11-28 13:55:12 +08:00
amtoaer
dc612ec6f1 feat: 加入 recheck 功能,在本地文件被删除后重新标记为未下载 2023-11-26 12:51:27 +08:00
8 changed files with 187 additions and 53 deletions

View File

@@ -0,0 +1,29 @@
name: Docker Image CI (DEBUG)
on:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@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
push: true
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/bili-sync:debug

View File

@@ -37,15 +37,6 @@ class Config(DataClassJsonMixin):
即:我们可以通过运行一次程序,等程序写入初始配置并提示配置错误终止后编辑 `config.json` 文件,编辑后即可重新运行。
## 关于 UP 头像
目前开放全局的环境变量 `THUMB_PATH` 作为 up 主头像的存储位置。
在下载某条视频时,如果 UP 的头像还不存在,就会将 UP 的头像下载至 `THUMB_PATH`,同时在视频的 NFO 文件中写入 UP 头像的绝对路径。
但实际测试下来EMBY 似乎无法正常读取 NFO 文件中的本地头像路径,待找到处理办法后再修复。
> 虽然但是,一个基本的逻辑是,如果期望 `bili-sync` 在 NFO 中写入的头像绝对路径能够被 EMBY 读取到,那么两个容器中头像的绝对路径必须完全相同。因此虽然头像还没办法正常加载,但为后续考虑,还是推荐将 THUMB_PATH 填写上,并确保该路径在 `bili-sync` 和 `emby` 两个容器中指向的是相同的文件夹(也就是把一个文件夹同时挂载到 `bili-sync` 和 `emby` 的 THUMB_PATH 下)。
## Docker 运行示例
@@ -60,17 +51,16 @@ services:
- /home/amtoaer/Videos/Bilibilis/:/Videos/Bilibilis/ # 视频文件
- /home/amtoaer/.config/nas/bili-sync/config/:/app/config/ # 配置文件
- /home/amtoaer/.config/nas/bili-sync/data/:/app/data/ # 数据库
# 注:如需在 emby 内查看 up 主头像,需要将 emby 的 metadata/people/ 配置目录挂载至容器的 /app/thumb/
- /home/amtoaer/.config/nas/emby/metadata/people/:/app/thumb/
environment:
- THUMB_PATH=/Videos/Bilibilis/thumb/ # 将头像放到视频文件的 thumb 文件夹下
- TZ=Asia/Shanghai
restart: always
network_mode: bridge
hostname: bili-sync
container_name: bili-sync
logging:
driver: "json-file"
options:
max-size: "30m"
driver: "local"
```
对应的配置文件:
@@ -92,10 +82,6 @@ services:
}
```
## 目前的问题
- [ ] 研究一下 NFO看看怎么正常读取本地的演员头像
## 路线图
- [x] 凭证认证

62
commands.py Normal file
View File

@@ -0,0 +1,62 @@
import asyncio
from loguru import logger
from constants import MediaStatus, MediaType
from models import FavoriteItem, Upper
from processor import download_content
from utils import aexists, amakedirs
async def recheck():
"""刷新数据库中视频的状态,如果发现文件不存在则标记未下载,以便在下次任务重新下载,在自己手动删除文件后调用"""
items = await FavoriteItem.filter(
type=MediaType.VIDEO,
status=MediaStatus.NORMAL,
downloaded=True,
)
exists = await asyncio.gather(*[aexists(item.video_path) for item in items])
for item, exist in zip(items, exists):
if isinstance(exist, Exception):
logger.error(
"Error when checking file {} {}: {}",
item.bvid,
item.name,
exist,
)
continue
if not exist:
logger.info(
"File {} {} not exists, mark as not downloaded.",
item.bvid,
item.name,
)
item.downloaded = False
logger.info("Updating database...")
await FavoriteItem.bulk_update(items, fields=["downloaded"])
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),
]
)
await asyncio.gather(*makedir_tasks)
await asyncio.gather(*other_tasks)
logger.info("All done.")

View File

@@ -4,6 +4,7 @@ import sys
import uvloop
from loguru import logger
from commands import recheck, upper_thumb
from models import init_model
from processor import cleanup, process
from settings import settings
@@ -13,11 +14,15 @@ asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
async def entry() -> None:
await init_model()
if any("once" in _ for _ in sys.argv):
# 单次运行
logger.info("Running once...")
await process()
return
for command, func in [
("once", process),
("recheck", recheck),
("upper_thumb", upper_thumb),
]:
if any(command in _ for _ in sys.argv):
logger.info("Running {}...", command)
await func()
return
logger.info("Running daemon...")
while True:
await process()

View File

@@ -13,6 +13,7 @@ from constants import (
MediaType,
)
from settings import settings
from utils import aopen
class FavoriteList(Model):
@@ -39,7 +40,31 @@ class Upper(Model):
@property
def thumb_path(self) -> Path:
return DEFAULT_THUMB_PATH / f"{self.mid}.jpg"
return (
DEFAULT_THUMB_PATH / str(self.mid)[0] / f"{self.mid}" / "folder.jpg"
)
@property
def meta_path(self) -> Path:
return (
DEFAULT_THUMB_PATH / str(self.mid)[0] / f"{self.mid}" / "person.nfo"
)
async def save_metadata(self):
async with aopen(self.meta_path, "w") as f:
await f.write(
f"""
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<person>
<plot />
<outline />
<lockdata>false</lockdata>
<dateadded>{self.created_at.strftime("%Y-%m-%d %H:%M:%S")}</dateadded>
<title>{self.mid}</title>
<sorttitle>{self.mid}</sorttitle>
</person>
""".strip()
)
class FavoriteItem(Model):

10
nfo.py
View File

@@ -2,19 +2,19 @@ import datetime
from dataclasses import dataclass
from pathlib import Path
from utils import aopen
@dataclass
class Actor:
name: str
role: str
thumb: Path
def to_xml(self) -> str:
return f"""
<actor>
<name>{self.name}</name>
<role>{self.role}</role>
<thumb>{self.thumb.resolve()}</thumb>
</actor>
""".strip(
"\n"
@@ -29,9 +29,9 @@ class EpisodeInfo:
bvid: str
aired: datetime.datetime
def write_nfo(self, path: Path) -> None:
with path.open("w", encoding="utf-8") as f:
f.write(self.to_xml())
async def write_nfo(self, path: Path) -> None:
async with aopen(path, "w", encoding="utf-8") as f:
await f.write(self.to_xml())
def to_xml(self) -> str:
actor = "\n".join(_.to_xml() for _ in self.actor)

View File

@@ -2,11 +2,8 @@ import asyncio
import datetime
from asyncio import Semaphore, create_subprocess_exec
from asyncio.subprocess import DEVNULL
from pathlib import Path
import aiofiles
import httpx
from bilibili_api import HEADERS, favorite_list, video
from bilibili_api import favorite_list, video
from bilibili_api.exceptions import ResponseCodeException
from loguru import logger
from tortoise import Tortoise
@@ -16,8 +13,9 @@ from credential import credential
from models import FavoriteItem, FavoriteList, Upper
from nfo import Actor, EpisodeInfo
from settings import settings
from utils import aexists, amakedirs, client, download_content
client = httpx.AsyncClient(headers=HEADERS)
anchor = datetime.date.today()
async def cleanup() -> None:
@@ -38,16 +36,6 @@ def concurrent_decorator(concurrency: int) -> callable:
return decorator
async def download_content(url: str, path: Path) -> None:
async with client.stream("GET", url) as resp, aiofiles.open(
path, "wb"
) as f:
async for chunk in resp.aiter_bytes(40960):
if not chunk:
return
await f.write(chunk)
async def manage_model(medias: list[dict], fav_list: FavoriteList) -> None:
uppers = [
Upper(
@@ -93,12 +81,12 @@ async def manage_model(medias: list[dict], fav_list: FavoriteList) -> None:
async def process() -> None:
global anchor
if not await credential.check_valid():
logger.error("Credential is invalid, skipped.")
return
if await credential.check_refresh():
if (
today := datetime.date.today()
) > anchor and await credential.check_refresh():
try:
credential.refresh()
await credential.refresh()
anchor = today
logger.info("Credential refreshed.")
except Exception:
logger.exception("Failed to refresh credential.")
@@ -174,7 +162,7 @@ async def process_video(fav_item: FavoriteItem) -> None:
logger.warning("Media {} is not a video, skipped.", fav_item.name)
return
try:
if fav_item.video_path.exists():
if await aexists(fav_item.video_path):
fav_item.downloaded = True
await fav_item.save()
logger.info(
@@ -182,19 +170,25 @@ async def process_video(fav_item: FavoriteItem) -> None:
)
return
# 写入 up 主头像
if not fav_item.upper.thumb_path.exists():
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
EpisodeInfo(
await EpisodeInfo(
title=fav_item.name,
plot=fav_item.desc,
actor=[
Actor(
name=fav_item.upper.mid,
role=fav_item.upper.name,
thumb=fav_item.upper.thumb_path,
)
],
bvid=fav_item.bvid,

33
utils.py Normal file
View File

@@ -0,0 +1,33 @@
from pathlib import Path
import aiofiles
import httpx
from aiofiles.base import AiofilesContextManager
from aiofiles.os import makedirs
from aiofiles.ospath import exists
from aiofiles.threadpool.text import AsyncTextIOWrapper
from bilibili_api import HEADERS
client = httpx.AsyncClient(headers=HEADERS)
async def download_content(url: str, path: Path) -> None:
async with client.stream("GET", url) as resp, aopen(path, "wb") as f:
async for chunk in resp.aiter_bytes(40960):
if not chunk:
return
await f.write(chunk)
async def aexists(path: Path) -> bool:
return await exists(path)
async def amakedirs(path: Path, exist_ok=False) -> None:
await makedirs(path, exist_ok=exist_ok)
def aopen(
path: Path, mode: str = "r", **kwargs
) -> AiofilesContextManager[None, None, AsyncTextIOWrapper]:
return aiofiles.open(path, mode, **kwargs)