mirror of
https://github.com/amtoaer/bili-sync.git
synced 2026-09-04 23:18:02 +08:00
feat: 支持分 p 视频下载,待额外测试 (#24)
This commit is contained in:
+9
-41
@@ -13,27 +13,14 @@ from utils import aexists, aremove
|
|||||||
|
|
||||||
async def recheck():
|
async def recheck():
|
||||||
"""刷新数据库中视频的状态,如果发现文件不存在则标记未下载,以便在下次任务重新下载,在自己手动删除文件后调用"""
|
"""刷新数据库中视频的状态,如果发现文件不存在则标记未下载,以便在下次任务重新下载,在自己手动删除文件后调用"""
|
||||||
items = await FavoriteItem.filter(
|
items = await FavoriteItem.filter(type=MediaType.VIDEO, status=MediaStatus.NORMAL, downloaded=True)
|
||||||
type=MediaType.VIDEO,
|
|
||||||
status=MediaStatus.NORMAL,
|
|
||||||
downloaded=True,
|
|
||||||
)
|
|
||||||
exists = await asyncio.gather(*[aexists(item.video_path) for item in items])
|
exists = await asyncio.gather(*[aexists(item.video_path) for item in items])
|
||||||
for item, exist in zip(items, exists):
|
for item, exist in zip(items, exists):
|
||||||
if isinstance(exist, Exception):
|
if isinstance(exist, Exception):
|
||||||
logger.error(
|
logger.error("Error when checking file {} {}: {}.", item.bvid, item.name, exist)
|
||||||
"Error when checking file {} {}: {}",
|
|
||||||
item.bvid,
|
|
||||||
item.name,
|
|
||||||
exist,
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
if not exist:
|
if not exist:
|
||||||
logger.info(
|
logger.info("File {} {} not exists, mark as not downloaded.", item.bvid, item.name)
|
||||||
"File {} {} not exists, mark as not downloaded.",
|
|
||||||
item.bvid,
|
|
||||||
item.name,
|
|
||||||
)
|
|
||||||
item.downloaded = False
|
item.downloaded = False
|
||||||
logger.info("Updating database...")
|
logger.info("Updating database...")
|
||||||
await FavoriteItem.bulk_update(items, fields=["downloaded"])
|
await FavoriteItem.bulk_update(items, fields=["downloaded"])
|
||||||
@@ -52,10 +39,7 @@ async def _refresh_favorite_item_info(
|
|||||||
items = await FavoriteItem.filter(downloaded=True).prefetch_related("upper")
|
items = await FavoriteItem.filter(downloaded=True).prefetch_related("upper")
|
||||||
if force:
|
if force:
|
||||||
# 如果强制刷新,那么就先把现存的所有内容删除
|
# 如果强制刷新,那么就先把现存的所有内容删除
|
||||||
await asyncio.gather(
|
await asyncio.gather(*[aremove(path) for item in items for path in path_getter(item)], return_exceptions=True)
|
||||||
*[aremove(path) for item in items for path in path_getter(item)],
|
|
||||||
return_exceptions=True,
|
|
||||||
)
|
|
||||||
await asyncio.gather(
|
await asyncio.gather(
|
||||||
*[
|
*[
|
||||||
process_favorite_item(
|
process_favorite_item(
|
||||||
@@ -72,30 +56,14 @@ async def _refresh_favorite_item_info(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
refresh_nfo = functools.partial(
|
refresh_nfo = functools.partial(_refresh_favorite_item_info, lambda item: [item.nfo_path], process_nfo=True)
|
||||||
_refresh_favorite_item_info, lambda item: [item.nfo_path], process_nfo=True
|
|
||||||
)
|
|
||||||
|
|
||||||
refresh_poster = functools.partial(
|
refresh_poster = functools.partial(_refresh_favorite_item_info, lambda item: [item.poster_path], process_poster=True)
|
||||||
_refresh_favorite_item_info,
|
|
||||||
lambda item: [item.poster_path],
|
|
||||||
process_poster=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
refresh_video = functools.partial(
|
refresh_video = functools.partial(_refresh_favorite_item_info, lambda item: [item.video_path], process_video=True)
|
||||||
_refresh_favorite_item_info,
|
|
||||||
lambda item: [item.video_path],
|
|
||||||
process_video=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
refresh_upper = functools.partial(
|
refresh_upper = functools.partial(_refresh_favorite_item_info, lambda item: item.upper_path, process_upper=True)
|
||||||
_refresh_favorite_item_info,
|
|
||||||
lambda item: item.upper_path,
|
|
||||||
process_upper=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
refresh_subtitle = functools.partial(
|
refresh_subtitle = functools.partial(
|
||||||
_refresh_favorite_item_info,
|
_refresh_favorite_item_info, lambda item: [item.subtitle_path], process_subtitle=True
|
||||||
lambda item: [item.subtitle_path],
|
|
||||||
process_subtitle=True,
|
|
||||||
)
|
)
|
||||||
|
|||||||
+10
-16
@@ -4,11 +4,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
|
|
||||||
def get_base(dir_name: str) -> Path:
|
def get_base(dir_name: str) -> Path:
|
||||||
path = (
|
path = Path(base) if (base := os.getenv(f"{dir_name.upper()}_PATH")) else Path(__file__).parent / dir_name
|
||||||
Path(base)
|
|
||||||
if (base := os.getenv(f"{dir_name.upper()}_PATH"))
|
|
||||||
else Path(__file__).parent / dir_name
|
|
||||||
)
|
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
@@ -37,20 +33,18 @@ class MediaStatus(IntEnum):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def text(self) -> str:
|
def text(self) -> str:
|
||||||
return {
|
return {MediaStatus.NORMAL: "normal", MediaStatus.INVISIBLE: "invisible", MediaStatus.DELETED: "deleted"}[self]
|
||||||
MediaStatus.NORMAL: "normal",
|
|
||||||
MediaStatus.INVISIBLE: "invisible",
|
|
||||||
MediaStatus.DELETED: "deleted",
|
class NfoMode(IntEnum):
|
||||||
}[self]
|
MOVIE = 1
|
||||||
|
TVSHOW = 2
|
||||||
|
EPISODE = 3
|
||||||
|
UPPER = 4
|
||||||
|
|
||||||
|
|
||||||
TORTOISE_ORM = {
|
TORTOISE_ORM = {
|
||||||
"connections": {"default": f"sqlite://{DEFAULT_DATABASE_PATH}"},
|
"connections": {"default": f"sqlite://{DEFAULT_DATABASE_PATH}"},
|
||||||
"apps": {
|
"apps": {"models": {"models": ["models", "aerich.models"], "default_connection": "default"}},
|
||||||
"models": {
|
|
||||||
"models": ["models", "aerich.models"],
|
|
||||||
"default_connection": "default",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"use_tz": True,
|
"use_tz": True,
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-11
@@ -6,21 +6,12 @@ from settings import settings
|
|||||||
class PersistedCredential(Credential):
|
class PersistedCredential(Credential):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
settings.sessdata,
|
settings.sessdata, settings.bili_jct, settings.buvid3, settings.dedeuserid, settings.ac_time_value
|
||||||
settings.bili_jct,
|
|
||||||
settings.buvid3,
|
|
||||||
settings.dedeuserid,
|
|
||||||
settings.ac_time_value,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def refresh(self) -> None:
|
async def refresh(self) -> None:
|
||||||
await super().refresh()
|
await super().refresh()
|
||||||
(
|
(settings.sessdata, settings.bili_jct, settings.dedeuserid, settings.ac_time_value) = (
|
||||||
settings.sessdata,
|
|
||||||
settings.bili_jct,
|
|
||||||
settings.dedeuserid,
|
|
||||||
settings.ac_time_value,
|
|
||||||
) = (
|
|
||||||
self.sessdata,
|
self.sessdata,
|
||||||
self.bili_jct,
|
self.bili_jct,
|
||||||
self.dedeuserid,
|
self.dedeuserid,
|
||||||
|
|||||||
@@ -6,14 +6,7 @@ import sys
|
|||||||
import uvloop
|
import uvloop
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from commands import (
|
from commands import recheck, refresh_nfo, refresh_poster, refresh_subtitle, refresh_upper, refresh_video
|
||||||
recheck,
|
|
||||||
refresh_nfo,
|
|
||||||
refresh_poster,
|
|
||||||
refresh_subtitle,
|
|
||||||
refresh_upper,
|
|
||||||
refresh_video,
|
|
||||||
)
|
|
||||||
from models import init_model
|
from models import init_model
|
||||||
from processor import cleanup, process
|
from processor import cleanup, process
|
||||||
from settings import settings
|
from settings import settings
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from tortoise import BaseDBAsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
return """
|
||||||
|
CREATE TABLE IF NOT EXISTS "favoriteitempage" (
|
||||||
|
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
"cid" INT NOT NULL,
|
||||||
|
"page" INT NOT NULL,
|
||||||
|
"name" VARCHAR(255) NOT NULL,
|
||||||
|
"image" TEXT NOT NULL,
|
||||||
|
"status" SMALLINT NOT NULL DEFAULT 1 /* NORMAL: 1\nINVISIBLE: 2\nDELETED: 3 */,
|
||||||
|
"downloaded" INT NOT NULL DEFAULT 0,
|
||||||
|
"favorite_item_id" INT NOT NULL REFERENCES "favoriteitem" ("id") ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "uid_favoriteite_favorit_c3b50e" UNIQUE ("favorite_item_id", "page")
|
||||||
|
) /* 收藏条目的分p */;"""
|
||||||
|
|
||||||
|
|
||||||
|
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
return """
|
||||||
|
DROP TABLE IF EXISTS "favoriteitempage";"""
|
||||||
@@ -3,17 +3,11 @@ from asyncio import create_subprocess_exec
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from tortoise import Tortoise, fields
|
from tortoise import Tortoise, fields
|
||||||
|
from tortoise.fields import Field
|
||||||
from tortoise.models import Model
|
from tortoise.models import Model
|
||||||
|
|
||||||
from constants import (
|
from constants import DEFAULT_THUMB_PATH, MIGRATE_COMMAND, TORTOISE_ORM, MediaStatus, MediaType
|
||||||
DEFAULT_THUMB_PATH,
|
|
||||||
MIGRATE_COMMAND,
|
|
||||||
TORTOISE_ORM,
|
|
||||||
MediaStatus,
|
|
||||||
MediaType,
|
|
||||||
)
|
|
||||||
from settings import settings
|
from settings import settings
|
||||||
from utils import aopen
|
|
||||||
from version import VERSION
|
from version import VERSION
|
||||||
|
|
||||||
|
|
||||||
@@ -47,22 +41,6 @@ class Upper(Model):
|
|||||||
def meta_path(self) -> Path:
|
def meta_path(self) -> Path:
|
||||||
return DEFAULT_THUMB_PATH / str(self.mid)[0] / f"{self.mid}" / "person.nfo"
|
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):
|
class FavoriteItem(Model):
|
||||||
"""收藏条目"""
|
"""收藏条目"""
|
||||||
@@ -75,8 +53,8 @@ class FavoriteItem(Model):
|
|||||||
desc = fields.TextField()
|
desc = fields.TextField()
|
||||||
cover = fields.TextField()
|
cover = fields.TextField()
|
||||||
tags = fields.JSONField(null=True)
|
tags = fields.JSONField(null=True)
|
||||||
favorite_list = fields.ForeignKeyField("models.FavoriteList", related_name="items")
|
favorite_list: Field[FavoriteList] = fields.ForeignKeyField("models.FavoriteList", related_name="items")
|
||||||
upper = fields.ForeignKeyField("models.Upper", related_name="uploads")
|
upper: Field[Upper] = fields.ForeignKeyField("models.Upper", related_name="uploads")
|
||||||
ctime = fields.DatetimeField()
|
ctime = fields.DatetimeField()
|
||||||
pubtime = fields.DatetimeField()
|
pubtime = fields.DatetimeField()
|
||||||
fav_time = fields.DatetimeField()
|
fav_time = fields.DatetimeField()
|
||||||
@@ -113,15 +91,92 @@ class FavoriteItem(Model):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def upper_path(self) -> list[Path]:
|
def upper_path(self) -> list[Path]:
|
||||||
return [
|
return [self.upper.thumb_path, self.upper.meta_path]
|
||||||
self.upper.thumb_path,
|
|
||||||
self.upper.meta_path,
|
|
||||||
]
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def subtitle_path(self) -> Path:
|
def subtitle_path(self) -> Path:
|
||||||
return Path(settings.path_mapper[self.favorite_list_id]) / f"{self.bvid}.zh-CN.default.ass"
|
return Path(settings.path_mapper[self.favorite_list_id]) / f"{self.bvid}.zh-CN.default.ass"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tvshow_nfo_path(self) -> Path:
|
||||||
|
"""分p视频时使用"""
|
||||||
|
return Path(settings.path_mapper[self.favorite_list_id]) / self.bvid / "tvshow.nfo"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tvshow_poster_path(self) -> Path:
|
||||||
|
"""分p视频时使用"""
|
||||||
|
return Path(settings.path_mapper[self.favorite_list_id]) / self.bvid / "poster.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
class FavoriteItemPage(Model):
|
||||||
|
"""收藏条目的分p"""
|
||||||
|
|
||||||
|
id = fields.IntField(pk=True)
|
||||||
|
favorite_item: Field[FavoriteItem] = fields.ForeignKeyField("models.FavoriteItem", related_name="pages")
|
||||||
|
cid = fields.IntField()
|
||||||
|
page = fields.IntField()
|
||||||
|
name = fields.CharField(max_length=255)
|
||||||
|
image = fields.TextField()
|
||||||
|
status = fields.IntEnumField(enum_type=MediaStatus, default=MediaStatus.NORMAL)
|
||||||
|
downloaded = fields.BooleanField(default=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = (("favorite_item_id", "page"),)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tmp_video_path(self) -> Path:
|
||||||
|
return (
|
||||||
|
Path(settings.path_mapper[self.favorite_item.favorite_list_id])
|
||||||
|
/ self.favorite_item.bvid
|
||||||
|
/ "Season 1"
|
||||||
|
/ f"tmp_{self.favorite_item.bvid} - S01E{f'{self.page:02d}'}_video"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tmp_audio_path(self) -> Path:
|
||||||
|
return (
|
||||||
|
Path(settings.path_mapper[self.favorite_item.favorite_list_id])
|
||||||
|
/ self.favorite_item.bvid
|
||||||
|
/ "Season 1"
|
||||||
|
/ f"tmp_{self.favorite_item.bvid} - S01E{f'{self.page:02d}'}_audio"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def video_path(self) -> Path:
|
||||||
|
return (
|
||||||
|
Path(settings.path_mapper[self.favorite_item.favorite_list_id])
|
||||||
|
/ self.favorite_item.bvid
|
||||||
|
/ "Season 1"
|
||||||
|
/ f"{self.favorite_item.bvid} - S01E{f'{self.page:02d}'}.mp4"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def nfo_path(self) -> Path:
|
||||||
|
return (
|
||||||
|
Path(settings.path_mapper[self.favorite_item.favorite_list_id])
|
||||||
|
/ self.favorite_item.bvid
|
||||||
|
/ "Season 1"
|
||||||
|
/ f"{self.favorite_item.bvid} - S01E{f'{self.page:02d}'}.nfo"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def poster_path(self) -> Path:
|
||||||
|
return (
|
||||||
|
Path(settings.path_mapper[self.favorite_item.favorite_list_id])
|
||||||
|
/ self.favorite_item.bvid
|
||||||
|
/ "Season 1"
|
||||||
|
/ f"{self.favorite_item.bvid} - S01E{f'{self.page:02d}'}-thumb.jpg"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subtitle_path(self) -> Path:
|
||||||
|
return (
|
||||||
|
Path(settings.path_mapper[self.favorite_item.favorite_list_id])
|
||||||
|
/ self.favorite_item.bvid
|
||||||
|
/ "Season 1"
|
||||||
|
/ f"{self.favorite_item.bvid} - S01E{f'{self.page:02d}'}.zh-CN.default.ass"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Program(Model):
|
class Program(Model):
|
||||||
id = fields.IntField(pk=True)
|
id = fields.IntField(pk=True)
|
||||||
@@ -131,17 +186,11 @@ class Program(Model):
|
|||||||
async def init_model() -> None:
|
async def init_model() -> None:
|
||||||
await Tortoise.init(config=TORTOISE_ORM)
|
await Tortoise.init(config=TORTOISE_ORM)
|
||||||
migrate_commands = (
|
migrate_commands = (
|
||||||
[MIGRATE_COMMAND, "upgrade"]
|
[MIGRATE_COMMAND, "upgrade"] if os.getenv("BILI_IN_DOCKER") else ["poetry", "run", MIGRATE_COMMAND, "upgrade"]
|
||||||
if os.getenv("BILI_IN_DOCKER")
|
|
||||||
else ["poetry", "run", MIGRATE_COMMAND, "upgrade"]
|
|
||||||
)
|
)
|
||||||
process = await create_subprocess_exec(*migrate_commands)
|
process = await create_subprocess_exec(*migrate_commands)
|
||||||
await process.communicate()
|
await process.communicate()
|
||||||
program, created = await Program.get_or_create(
|
program, created = await Program.get_or_create(defaults={"version": VERSION})
|
||||||
defaults={
|
|
||||||
"version": VERSION,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if created or program.version != VERSION:
|
if created or program.version != VERSION:
|
||||||
# 把新版本的迁移逻辑写在这里
|
# 把新版本的迁移逻辑写在这里
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -1,26 +1,73 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
from abc import abstractmethod
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from models import FavoriteItem, FavoriteItemPage, Upper
|
||||||
from utils import aopen
|
from utils import aopen
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Actor:
|
class Base:
|
||||||
|
"""基类,有个工具方法"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def to_xml(self) -> str:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def to_file(self, path: Path) -> None:
|
||||||
|
"""把 xml 写入文件"""
|
||||||
|
async with aopen(path, "w", encoding="utf-8") as f:
|
||||||
|
await f.write(self.to_xml())
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EpisodeInfo(Base):
|
||||||
|
"""分p的单集信息"""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
season: int
|
||||||
|
episode: int
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_favorite_item_page(page: FavoriteItemPage) -> "EpisodeInfo":
|
||||||
|
return EpisodeInfo(title=page.name, season=1, episode=page.page)
|
||||||
|
|
||||||
|
def to_xml(self) -> str:
|
||||||
|
return f"""
|
||||||
|
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||||
|
<episodedetails>
|
||||||
|
<plot />
|
||||||
|
<outline />
|
||||||
|
<title>{self.title}</title>
|
||||||
|
<season>{self.season}</season>
|
||||||
|
<episode>{self.episode}</episode>
|
||||||
|
</episodedetails>
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Actor(Base):
|
||||||
name: str
|
name: str
|
||||||
role: str
|
role: str
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_upper(upper: Upper) -> "Actor":
|
||||||
|
return Actor(name=upper.mid, role=upper.name)
|
||||||
|
|
||||||
def to_xml(self) -> str:
|
def to_xml(self) -> str:
|
||||||
return f"""
|
return f"""
|
||||||
<actor>
|
<actor>
|
||||||
<name>{self.name}</name>
|
<name>{self.name}</name>
|
||||||
<role>{self.role}</role>
|
<role>{self.role}</role>
|
||||||
</actor>
|
</actor>
|
||||||
""".strip("\n")
|
""".strip()
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class EpisodeInfo:
|
class MovieInfo(Base):
|
||||||
|
"""单p的视频信息"""
|
||||||
|
|
||||||
title: str
|
title: str
|
||||||
plot: str
|
plot: str
|
||||||
tags: list[str]
|
tags: list[str]
|
||||||
@@ -28,20 +75,23 @@ class EpisodeInfo:
|
|||||||
bvid: str
|
bvid: str
|
||||||
aired: datetime.datetime
|
aired: datetime.datetime
|
||||||
|
|
||||||
async def write_nfo(self, path: Path) -> None:
|
@staticmethod
|
||||||
async with aopen(path, "w", encoding="utf-8") as f:
|
def from_favorite_item(fav_item: FavoriteItem) -> "MovieInfo":
|
||||||
await f.write(self.to_xml())
|
return MovieInfo(
|
||||||
|
title=fav_item.name,
|
||||||
|
plot=fav_item.desc,
|
||||||
|
actor=[Actor.from_upper(fav_item.upper)],
|
||||||
|
tags=fav_item.tags,
|
||||||
|
bvid=fav_item.bvid,
|
||||||
|
aired=fav_item.ctime,
|
||||||
|
)
|
||||||
|
|
||||||
def to_xml(self) -> str:
|
def to_xml(self) -> str:
|
||||||
actor = "\n".join(_.to_xml() for _ in self.actor)
|
actor = "\n".join(_.to_xml() for _ in self.actor)
|
||||||
tags = (
|
tags = "\n".join(f" <genre>{_}</genre>" for _ in self.tags) if isinstance(self.tags, list) else ""
|
||||||
"\n".join(f" <genre>{_}</genre>" for _ in self.tags)
|
|
||||||
if isinstance(self.tags, list)
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
return f"""
|
return f"""
|
||||||
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||||
<episodedetails>
|
<movie>
|
||||||
<plot><![CDATA[{self.plot}]]></plot>
|
<plot><![CDATA[{self.plot}]]></plot>
|
||||||
<outline />
|
<outline />
|
||||||
<title>{self.title}</title>
|
<title>{self.title}</title>
|
||||||
@@ -50,5 +100,65 @@ class EpisodeInfo:
|
|||||||
{tags}
|
{tags}
|
||||||
<uniqueid type="bilibili">{self.bvid}</uniqueid>
|
<uniqueid type="bilibili">{self.bvid}</uniqueid>
|
||||||
<aired>{self.aired.strftime("%Y-%m-%d")}</aired>
|
<aired>{self.aired.strftime("%Y-%m-%d")}</aired>
|
||||||
</episodedetails>
|
</movie>
|
||||||
""".strip("\n")
|
""".strip()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TVShowInfo(Base):
|
||||||
|
title: str
|
||||||
|
plot: str
|
||||||
|
tags: list[str]
|
||||||
|
actor: list[Actor]
|
||||||
|
bvid: str
|
||||||
|
aired: datetime.datetime
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_favorite_item(fav_item: FavoriteItem) -> "TVShowInfo":
|
||||||
|
return TVShowInfo(
|
||||||
|
title=fav_item.name,
|
||||||
|
plot=fav_item.desc,
|
||||||
|
actor=[Actor.from_upper(fav_item.upper)],
|
||||||
|
tags=fav_item.tags,
|
||||||
|
bvid=fav_item.bvid,
|
||||||
|
aired=fav_item.ctime,
|
||||||
|
)
|
||||||
|
|
||||||
|
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"?>
|
||||||
|
<tvshow>
|
||||||
|
<plot><![CDATA[{self.plot}]]></plot>
|
||||||
|
<outline />
|
||||||
|
<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>
|
||||||
|
</tvshow>
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UpperInfo(Base):
|
||||||
|
mid: int
|
||||||
|
created_at: datetime.datetime
|
||||||
|
|
||||||
|
def from_upper(upper: Upper) -> "UpperInfo":
|
||||||
|
return UpperInfo(mid=upper.mid, created_at=upper.created_at)
|
||||||
|
|
||||||
|
def to_xml(self) -> str:
|
||||||
|
return 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()
|
||||||
|
|||||||
+303
-211
@@ -1,18 +1,21 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import datetime
|
import datetime
|
||||||
import itertools
|
|
||||||
from asyncio import Semaphore, create_subprocess_exec
|
from asyncio import Semaphore, create_subprocess_exec
|
||||||
from asyncio.subprocess import DEVNULL
|
from asyncio.subprocess import PIPE
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from bilibili_api import ass, favorite_list, video
|
from bilibili_api import ass, favorite_list, video
|
||||||
from bilibili_api.exceptions import ResponseCodeException
|
from bilibili_api.exceptions import ResponseCodeException
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from tortoise.connection import connections
|
from tortoise.connection import connections
|
||||||
|
from tortoise.models import Model
|
||||||
|
|
||||||
from constants import FFMPEG_COMMAND, MediaStatus, MediaType
|
from constants import FFMPEG_COMMAND, MediaStatus, MediaType, NfoMode
|
||||||
from credential import credential
|
from credential import credential
|
||||||
from models import FavoriteItem, FavoriteList, Upper
|
from models import FavoriteItem, FavoriteItemPage, FavoriteList, Upper
|
||||||
from nfo import Actor, EpisodeInfo
|
from nfo import Base as NfoBase
|
||||||
|
from nfo import EpisodeInfo, MovieInfo, TVShowInfo, UpperInfo
|
||||||
from settings import settings
|
from settings import settings
|
||||||
from utils import aexists, amakedirs, client, download_content
|
from utils import aexists, amakedirs, client, download_content
|
||||||
|
|
||||||
@@ -25,6 +28,7 @@ async def cleanup() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def concurrent_decorator(concurrency: int) -> callable:
|
def concurrent_decorator(concurrency: int) -> callable:
|
||||||
|
"""一个简单的并发限制装饰器,被装饰的函数同时仅能运行 concurrency 个"""
|
||||||
sem = Semaphore(value=concurrency)
|
sem = Semaphore(value=concurrency)
|
||||||
|
|
||||||
def decorator(func: callable) -> callable:
|
def decorator(func: callable) -> callable:
|
||||||
@@ -37,14 +41,10 @@ def concurrent_decorator(concurrency: int) -> callable:
|
|||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
async def manage_model(medias: list[dict], fav_list: FavoriteList) -> None:
|
async def update_favorite_item(medias: list[dict], fav_list: FavoriteList) -> None:
|
||||||
|
"""根据收藏夹里的视频列表更新数据库记录"""
|
||||||
uppers = [
|
uppers = [
|
||||||
Upper(
|
Upper(mid=media["upper"]["mid"], name=media["upper"]["name"], thumb=media["upper"]["face"]) for media in medias
|
||||||
mid=media["upper"]["mid"],
|
|
||||||
name=media["upper"]["name"],
|
|
||||||
thumb=media["upper"]["face"],
|
|
||||||
)
|
|
||||||
for media in medias
|
|
||||||
]
|
]
|
||||||
await Upper.bulk_create(uppers, on_conflict=["mid"], update_fields=["name", "thumb"])
|
await Upper.bulk_create(uppers, on_conflict=["mid"], update_fields=["name", "thumb"])
|
||||||
items = [
|
items = [
|
||||||
@@ -66,15 +66,7 @@ async def manage_model(medias: list[dict], fav_list: FavoriteList) -> None:
|
|||||||
await FavoriteItem.bulk_create(
|
await FavoriteItem.bulk_create(
|
||||||
items,
|
items,
|
||||||
on_conflict=["bvid", "favorite_list_id"],
|
on_conflict=["bvid", "favorite_list_id"],
|
||||||
update_fields=[
|
update_fields=["name", "type", "desc", "cover", "ctime", "pubtime", "fav_time"],
|
||||||
"name",
|
|
||||||
"type",
|
|
||||||
"desc",
|
|
||||||
"cover",
|
|
||||||
"ctime",
|
|
||||||
"pubtime",
|
|
||||||
"fav_time",
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -100,11 +92,7 @@ async def process_favorite(favorite_id: int) -> None:
|
|||||||
favorite_id, page=1, credential=credential
|
favorite_id, page=1, credential=credential
|
||||||
)
|
)
|
||||||
title = favorite_video_list["info"]["title"]
|
title = favorite_video_list["info"]["title"]
|
||||||
logger.info(
|
logger.info("Start to process favorite {}: {}.", favorite_id, title)
|
||||||
"Start to process favorite {}: {}",
|
|
||||||
favorite_id,
|
|
||||||
title,
|
|
||||||
)
|
|
||||||
fav_list, _ = await FavoriteList.get_or_create(
|
fav_list, _ = await FavoriteList.get_or_create(
|
||||||
id=favorite_id, defaults={"name": favorite_video_list["info"]["title"]}
|
id=favorite_id, defaults={"name": favorite_video_list["info"]["title"]}
|
||||||
)
|
)
|
||||||
@@ -118,32 +106,23 @@ async def process_favorite(favorite_id: int) -> None:
|
|||||||
)
|
)
|
||||||
# 先看看对应 bvid 的记录是否存在
|
# 先看看对应 bvid 的记录是否存在
|
||||||
existed_items = await FavoriteItem.filter(
|
existed_items = await FavoriteItem.filter(
|
||||||
favorite_list=fav_list,
|
favorite_list=fav_list, bvid__in=[media["bvid"] for media in favorite_video_list["medias"]]
|
||||||
bvid__in=[media["bvid"] for media in favorite_video_list["medias"]],
|
|
||||||
)
|
)
|
||||||
# 记录一下获得的列表中的 bvid 和 fav_time
|
# 记录一下获得的列表中的 bvid 和 fav_time
|
||||||
media_info = {(media["bvid"], media["fav_time"]) for media in favorite_video_list["medias"]}
|
media_info = {(media["bvid"], media["fav_time"]) for media in favorite_video_list["medias"]}
|
||||||
# 如果有 bvid 和 fav_time 都相同的记录,说明已经到达了上次处理到的位置
|
# 如果有 bvid 和 fav_time 都相同的记录,说明已经到达了上次处理到的位置
|
||||||
continue_flag = not media_info & {
|
continue_flag = not media_info & {(item.bvid, int(item.fav_time.timestamp())) for item in existed_items}
|
||||||
(item.bvid, int(item.fav_time.timestamp())) for item in existed_items
|
await update_favorite_item(favorite_video_list["medias"], fav_list)
|
||||||
}
|
|
||||||
await manage_model(favorite_video_list["medias"], fav_list)
|
|
||||||
if not (continue_flag and favorite_video_list["has_more"]):
|
if not (continue_flag and favorite_video_list["has_more"]):
|
||||||
break
|
break
|
||||||
all_unprocessed_items = await FavoriteItem.filter(
|
all_unprocessed_items = await FavoriteItem.filter(
|
||||||
favorite_list=fav_list,
|
favorite_list=fav_list, type=MediaType.VIDEO, status=MediaStatus.NORMAL, downloaded=False
|
||||||
type=MediaType.VIDEO,
|
|
||||||
status=MediaStatus.NORMAL,
|
|
||||||
downloaded=False,
|
|
||||||
).prefetch_related("upper")
|
).prefetch_related("upper")
|
||||||
await asyncio.gather(
|
await asyncio.gather(*[process_favorite_item(item) for item in all_unprocessed_items], return_exceptions=True)
|
||||||
*[process_favorite_item(item) for item in all_unprocessed_items],
|
logger.info("Favorite {} {} has been processed.", favorite_id, title)
|
||||||
return_exceptions=True,
|
|
||||||
)
|
|
||||||
logger.info("Favorite {} {} processed successfully.", favorite_id, title)
|
|
||||||
|
|
||||||
|
|
||||||
@concurrent_decorator(4)
|
@concurrent_decorator(concurrency=4)
|
||||||
async def process_favorite_item(
|
async def process_favorite_item(
|
||||||
fav_item: FavoriteItem,
|
fav_item: FavoriteItem,
|
||||||
process_poster=True,
|
process_poster=True,
|
||||||
@@ -152,202 +131,315 @@ async def process_favorite_item(
|
|||||||
process_upper=True,
|
process_upper=True,
|
||||||
process_subtitle=True,
|
process_subtitle=True,
|
||||||
) -> None:
|
) -> None:
|
||||||
logger.info("Start to process video {} {}", fav_item.bvid, fav_item.name)
|
logger.info("Start to process video {} {}.", fav_item.bvid, fav_item.name)
|
||||||
if fav_item.type != MediaType.VIDEO:
|
if fav_item.type != MediaType.VIDEO:
|
||||||
logger.warning("Media {} is not a video, skipped.", fav_item.name)
|
logger.warning("Media {} {} is not a video, skipped.", fav_item.bvid, fav_item.name)
|
||||||
return
|
return
|
||||||
v = video.Video(fav_item.bvid, credential=credential)
|
v = video.Video(fav_item.bvid, credential=credential)
|
||||||
# 如果没有获取过 tags,那么尝试获取一下
|
# 如果没有获取过 tags,那么尝试获取一下(不关键,忽略掉错误)
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
if fav_item.tags is None:
|
if fav_item.tags is None:
|
||||||
fav_item.tags = [_["tag_name"] for _ in await v.get_tags()]
|
fav_item.tags = [_["tag_name"] for _ in await v.get_tags()]
|
||||||
except Exception:
|
# 处理 up 主信息和是否分 p 无关,放到前面
|
||||||
logger.exception(
|
|
||||||
"Failed to get tags of video {} {}",
|
|
||||||
fav_item.bvid,
|
|
||||||
fav_item.name,
|
|
||||||
)
|
|
||||||
|
|
||||||
if process_upper:
|
if process_upper:
|
||||||
|
result = await asyncio.gather(
|
||||||
|
get_file(fav_item.upper.thumb, fav_item.upper.thumb_path),
|
||||||
|
get_nfo(fav_item.upper.meta_path, obj=fav_item.upper, mode=NfoMode.UPPER),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
if any(isinstance(_, FileExistsError) for _ in result):
|
||||||
|
logger.info("Upper {} {} already exists, skipped.", fav_item.upper.mid, fav_item.upper.name)
|
||||||
|
elif any(isinstance(_, Exception) for _ in result):
|
||||||
|
logger.exception("Failed to process upper {} {}.", fav_item.upper.mid, fav_item.upper.name)
|
||||||
|
single_page = False
|
||||||
|
if settings.paginated_video:
|
||||||
|
pages = None
|
||||||
try:
|
try:
|
||||||
if not all(
|
pages = await v.get_pages()
|
||||||
await asyncio.gather(
|
pages = [
|
||||||
aexists(fav_item.upper.thumb_path),
|
FavoriteItemPage(
|
||||||
aexists(fav_item.upper.meta_path),
|
favorite_item=fav_item,
|
||||||
|
cid=page["cid"],
|
||||||
|
page=page["page"],
|
||||||
|
name=page["part"],
|
||||||
|
image=page["first_frame"],
|
||||||
)
|
)
|
||||||
):
|
for page in pages
|
||||||
await amakedirs(fav_item.upper.thumb_path.parent, exist_ok=True)
|
]
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to get pages of video {} {}.", fav_item.bvid, fav_item.name)
|
||||||
|
if pages:
|
||||||
|
if len(pages) == 1:
|
||||||
|
single_page = True
|
||||||
|
else:
|
||||||
|
pages = await FavoriteItemPage.bulk_create(
|
||||||
|
pages, on_conflict=["favorite_item_id", "page"], update_fields=["cid", "name", "image"]
|
||||||
|
)
|
||||||
|
if process_nfo:
|
||||||
|
try:
|
||||||
|
await get_nfo(fav_item.tvshow_nfo_path, obj=fav_item, mode=NfoMode.TVSHOW)
|
||||||
|
except FileExistsError:
|
||||||
|
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:
|
||||||
|
await get_file(fav_item.cover, fav_item.tvshow_poster_path)
|
||||||
|
except FileExistsError:
|
||||||
|
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)
|
||||||
await asyncio.gather(
|
await asyncio.gather(
|
||||||
fav_item.upper.save_metadata(),
|
*[
|
||||||
download_content(fav_item.upper.thumb, fav_item.upper.thumb_path),
|
process_favorite_item_page(
|
||||||
|
page, v, process_poster, process_video, process_nfo, process_subtitle
|
||||||
|
)
|
||||||
|
for page in pages
|
||||||
|
],
|
||||||
return_exceptions=True,
|
return_exceptions=True,
|
||||||
)
|
)
|
||||||
else:
|
fav_item.downloaded = all(page.downloaded for page in pages)
|
||||||
logger.info(
|
page_status = {page.status for page in pages}
|
||||||
"Upper {} {} already exists, skipped.",
|
if MediaStatus.INVISIBLE in page_status:
|
||||||
fav_item.upper.mid,
|
fav_item.status = MediaStatus.INVISIBLE
|
||||||
fav_item.upper.name,
|
elif MediaStatus.DELETED in page_status:
|
||||||
)
|
fav_item.status = MediaStatus.DELETED
|
||||||
except Exception:
|
else:
|
||||||
logger.exception(
|
fav_item.status = MediaStatus.NORMAL
|
||||||
"Failed to process upper {} {}",
|
if single_page or not settings.paginated_video:
|
||||||
fav_item.upper.mid,
|
if process_nfo:
|
||||||
fav_item.upper.name,
|
try:
|
||||||
)
|
await get_nfo(fav_item.nfo_path, obj=fav_item, mode=NfoMode.MOVIE)
|
||||||
|
except FileExistsError:
|
||||||
if process_nfo:
|
logger.info("NFO of {} {} already exists, skipped.", fav_item.bvid, fav_item.name)
|
||||||
try:
|
except Exception:
|
||||||
if not await aexists(fav_item.nfo_path):
|
logger.exception("Failed to process nfo of video {} {}.", fav_item.bvid, fav_item.name)
|
||||||
await EpisodeInfo(
|
if process_poster:
|
||||||
title=fav_item.name,
|
try:
|
||||||
plot=fav_item.desc,
|
await get_file(fav_item.cover, fav_item.poster_path)
|
||||||
actor=[
|
except FileExistsError:
|
||||||
Actor(
|
logger.info("Poster of {} {} already exists, skipped.", fav_item.bvid, fav_item.name)
|
||||||
name=fav_item.upper.mid,
|
except Exception:
|
||||||
role=fav_item.upper.name,
|
logger.exception("Failed to process poster of video {} {}.", fav_item.bvid, fav_item.name)
|
||||||
)
|
if process_subtitle:
|
||||||
],
|
try:
|
||||||
tags=fav_item.tags,
|
await get_subtitle(v, 0, fav_item.subtitle_path)
|
||||||
bvid=fav_item.bvid,
|
except FileExistsError:
|
||||||
aired=fav_item.ctime,
|
logger.info("Subtitle of {} {} already exists, skipped.", fav_item.bvid, fav_item.name)
|
||||||
).write_nfo(fav_item.nfo_path)
|
except Exception:
|
||||||
else:
|
logger.exception("Failed to process subtitle of video {} {}.", fav_item.bvid, fav_item.name)
|
||||||
logger.info(
|
if process_video:
|
||||||
"NFO of {} {} already exists, skipped.",
|
try:
|
||||||
fav_item.bvid,
|
await get_video(v, 0, fav_item.tmp_video_path, fav_item.tmp_audio_path, fav_item.video_path)
|
||||||
fav_item.name,
|
fav_item.downloaded = True
|
||||||
)
|
except FileExistsError:
|
||||||
except Exception:
|
logger.info("Video {} {} already exists, skipped.", fav_item.bvid, fav_item.name)
|
||||||
logger.exception(
|
fav_item.downloaded = True
|
||||||
"Failed to process nfo of video {} {}",
|
except Exception as e:
|
||||||
fav_item.bvid,
|
errcode_status = {62002: MediaStatus.INVISIBLE, -404: MediaStatus.DELETED}
|
||||||
fav_item.name,
|
if not (isinstance(e, ResponseCodeException) and (status := errcode_status.get(e.code))):
|
||||||
)
|
logger.exception("Failed to process video {} {}.", fav_item.bvid, fav_item.name)
|
||||||
|
else:
|
||||||
if process_poster:
|
fav_item.status = status
|
||||||
try:
|
logger.error(
|
||||||
if not await aexists(fav_item.poster_path):
|
"Video {} {} is not available, marked as {}.",
|
||||||
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.bvid,
|
||||||
fav_item.name,
|
fav_item.name,
|
||||||
|
fav_item.status.text,
|
||||||
)
|
)
|
||||||
else:
|
await fav_item.save()
|
||||||
logger.info(
|
logger.info("{} {} has been processed.", fav_item.bvid, fav_item.name)
|
||||||
"Poster of {} {} already exists, skipped.",
|
|
||||||
fav_item.bvid,
|
|
||||||
fav_item.name,
|
@concurrent_decorator(concurrency=4)
|
||||||
)
|
async def process_favorite_item_page(
|
||||||
|
fav_page: FavoriteItemPage,
|
||||||
|
v: video.Video,
|
||||||
|
process_poster=True,
|
||||||
|
process_video=True,
|
||||||
|
process_nfo=True,
|
||||||
|
process_subtitle=True,
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
"Start to process video {} {} page {}.", fav_page.favorite_item.bvid, fav_page.favorite_item.name, fav_page.page
|
||||||
|
)
|
||||||
|
if process_nfo:
|
||||||
|
try:
|
||||||
|
await get_nfo(fav_page.nfo_path, obj=fav_page, mode=NfoMode.EPISODE)
|
||||||
|
except FileExistsError:
|
||||||
|
logger.info(
|
||||||
|
"NFO of {} {} page {} already exists, skipped.",
|
||||||
|
fav_page.favorite_item.bvid,
|
||||||
|
fav_page.favorite_item.name,
|
||||||
|
fav_page.page,
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Failed to process poster of video {} {}",
|
"Failed to process nfo of video {} {} page {}.",
|
||||||
fav_item.bvid,
|
fav_page.favorite_item.bvid,
|
||||||
fav_item.name,
|
fav_page.favorite_item.name,
|
||||||
|
fav_page.page,
|
||||||
|
)
|
||||||
|
if process_poster:
|
||||||
|
try:
|
||||||
|
await get_file(fav_page.image, fav_page.poster_path)
|
||||||
|
except FileExistsError:
|
||||||
|
logger.info(
|
||||||
|
"Poster of {} {} page {} already exists, skipped.",
|
||||||
|
fav_page.favorite_item.bvid,
|
||||||
|
fav_page.favorite_item.name,
|
||||||
|
fav_page.page,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to process poster of video {} {} page {}.",
|
||||||
|
fav_page.favorite_item.bvid,
|
||||||
|
fav_page.favorite_item.name,
|
||||||
|
fav_page.page,
|
||||||
)
|
)
|
||||||
|
|
||||||
if process_subtitle:
|
if process_subtitle:
|
||||||
try:
|
try:
|
||||||
if not await aexists(fav_item.subtitle_path):
|
await get_subtitle(v, fav_page.page - 1, fav_page.subtitle_path)
|
||||||
await ass.make_ass_file_danmakus_protobuf(
|
except FileExistsError:
|
||||||
v,
|
logger.info(
|
||||||
0,
|
"Subtitle of {} {} page {} already exists, skipped.",
|
||||||
str(fav_item.subtitle_path.resolve()),
|
fav_page.favorite_item.bvid,
|
||||||
credential=credential,
|
fav_page.favorite_item.name,
|
||||||
font_name=settings.subtitle.font_name,
|
fav_page.page,
|
||||||
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:
|
except Exception:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Failed to process subtitle of video {} {}",
|
"Failed to process subtitle of video {} {} page {}.",
|
||||||
fav_item.bvid,
|
fav_page.favorite_item.bvid,
|
||||||
fav_item.name,
|
fav_page.favorite_item.name,
|
||||||
|
fav_page.page,
|
||||||
)
|
)
|
||||||
if process_video:
|
if process_video:
|
||||||
try:
|
try:
|
||||||
if await aexists(fav_item.video_path):
|
await get_video(v, fav_page.page - 1, fav_page.tmp_video_path, fav_page.tmp_audio_path, fav_page.video_path)
|
||||||
fav_item.downloaded = True
|
fav_page.downloaded = True
|
||||||
logger.info(
|
except FileExistsError:
|
||||||
"Video {} {} already exists, skipped.",
|
logger.info(
|
||||||
fav_item.bvid,
|
"Video {} {} page {} already exists, skipped.",
|
||||||
fav_item.name,
|
fav_page.favorite_item.bvid,
|
||||||
|
fav_page.favorite_item.name,
|
||||||
|
fav_page.page,
|
||||||
|
)
|
||||||
|
fav_page.downloaded = True
|
||||||
|
except Exception as e:
|
||||||
|
errcode_status = {62002: MediaStatus.INVISIBLE, -404: MediaStatus.DELETED}
|
||||||
|
if not (isinstance(e, ResponseCodeException) and (status := errcode_status.get(e.code))):
|
||||||
|
logger.exception(
|
||||||
|
"Failed to process video {} {} page {}.",
|
||||||
|
fav_page.favorite_item.bvid,
|
||||||
|
fav_page.favorite_item.name,
|
||||||
|
fav_page.page,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# 开始处理视频内容
|
fav_page.status = status
|
||||||
detector = video.VideoDownloadURLDataDetecter(
|
|
||||||
await v.get_download_url(page_index=0)
|
|
||||||
)
|
|
||||||
streams = detector.detect_best_streams(codecs=settings.codec)
|
|
||||||
if detector.check_flv_stream():
|
|
||||||
await download_content(streams[0].url, fav_item.tmp_video_path)
|
|
||||||
process = await create_subprocess_exec(
|
|
||||||
FFMPEG_COMMAND,
|
|
||||||
"-i",
|
|
||||||
fav_item.tmp_video_path,
|
|
||||||
fav_item.video_path,
|
|
||||||
stdout=DEVNULL,
|
|
||||||
stderr=DEVNULL,
|
|
||||||
)
|
|
||||||
await process.communicate()
|
|
||||||
fav_item.tmp_video_path.unlink()
|
|
||||||
else:
|
|
||||||
paths, tasks = (
|
|
||||||
[fav_item.tmp_video_path],
|
|
||||||
[download_content(streams[0].url, fav_item.tmp_video_path)],
|
|
||||||
)
|
|
||||||
if streams[1]:
|
|
||||||
paths.append(fav_item.tmp_audio_path)
|
|
||||||
tasks.append(download_content(streams[1].url, fav_item.tmp_audio_path))
|
|
||||||
await asyncio.gather(*tasks)
|
|
||||||
process = await create_subprocess_exec(
|
|
||||||
FFMPEG_COMMAND,
|
|
||||||
*list(itertools.chain(*zip(["-i"] * len(paths), paths))),
|
|
||||||
"-c",
|
|
||||||
"copy",
|
|
||||||
fav_item.video_path,
|
|
||||||
stdout=DEVNULL,
|
|
||||||
stderr=DEVNULL,
|
|
||||||
)
|
|
||||||
await process.communicate()
|
|
||||||
for path in paths:
|
|
||||||
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(
|
logger.error(
|
||||||
"Video {} {} is not available, marked as {}",
|
"Video {} {} page {} is not available, marked as {}.",
|
||||||
fav_item.bvid,
|
fav_page.favorite_item.bvid,
|
||||||
fav_item.name,
|
fav_page.favorite_item.name,
|
||||||
fav_item.status.text,
|
fav_page.page,
|
||||||
|
fav_page.status.text,
|
||||||
)
|
)
|
||||||
except Exception:
|
await fav_page.save()
|
||||||
logger.exception("Failed to process video {} {}", fav_item.bvid, fav_item.name)
|
|
||||||
await fav_item.save()
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"{} {} is processed successfully.",
|
"{} {} page {} has been processed.", fav_page.favorite_item.bvid, fav_page.favorite_item.name, fav_page.page
|
||||||
fav_item.bvid,
|
|
||||||
fav_item.name,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_video(v: video.Video, page_id: int, tmp_video_path: Path, tmp_audio_path: Path, video_path: Path) -> None:
|
||||||
|
"""指定临时视频、音频和目标视频目录,下载视频的某个分p"""
|
||||||
|
if await aexists(video_path):
|
||||||
|
# 目标视频已经存在,忽略掉
|
||||||
|
raise FileExistsError
|
||||||
|
await amakedirs(video_path.parent, exist_ok=True)
|
||||||
|
# 分析对应分p的视频流
|
||||||
|
detector = video.VideoDownloadURLDataDetecter(await v.get_download_url(page_index=page_id))
|
||||||
|
streams = detector.detect_best_streams()
|
||||||
|
if detector.check_flv_stream():
|
||||||
|
# 对于 flv,直接下载
|
||||||
|
await download_content(streams[0].url, tmp_video_path)
|
||||||
|
process = await create_subprocess_exec(
|
||||||
|
FFMPEG_COMMAND, "-i", tmp_video_path, video_path, stdout=PIPE, stderr=PIPE
|
||||||
|
)
|
||||||
|
stdout, stderr = await process.communicate()
|
||||||
|
tmp_video_path.unlink(missing_ok=True)
|
||||||
|
else:
|
||||||
|
# 对于非 flv,首先要下载视频流
|
||||||
|
paths, tasks = ([tmp_video_path], [download_content(streams[0].url, tmp_video_path)])
|
||||||
|
if streams[1]:
|
||||||
|
# 如果有音频流,也下载
|
||||||
|
paths.append(tmp_audio_path)
|
||||||
|
tasks.append(download_content(streams[1].url, tmp_audio_path))
|
||||||
|
await asyncio.gather(*tasks)
|
||||||
|
process = await create_subprocess_exec(
|
||||||
|
FFMPEG_COMMAND,
|
||||||
|
*sum([["-i", path] for path in paths], []),
|
||||||
|
"-c",
|
||||||
|
"copy",
|
||||||
|
video_path,
|
||||||
|
stdout=PIPE,
|
||||||
|
stderr=PIPE,
|
||||||
|
)
|
||||||
|
stdout, stderr = await process.communicate()
|
||||||
|
for path in paths:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
if process.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"{FFMPEG_COMMAND} exited with non-zero code {process.returncode}."
|
||||||
|
f"\nstdout:\n{stdout.decode()}"
|
||||||
|
f"\nstderr:\n{stderr.decode()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_file(url: str, path: Path) -> None:
|
||||||
|
"""一个简单的下载封装,用于下载封面等内容"""
|
||||||
|
if await aexists(path):
|
||||||
|
# 目标文件已经存在,忽略掉
|
||||||
|
raise FileExistsError
|
||||||
|
await amakedirs(path.parent, exist_ok=True)
|
||||||
|
await download_content(url, path)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_subtitle(v: video.Video, page_id: int, subtitle_path: Path) -> None:
|
||||||
|
"""指定目标字幕文件,下载视频的某个分p的字幕"""
|
||||||
|
if await aexists(subtitle_path):
|
||||||
|
# 目标字幕已经存在,忽略掉
|
||||||
|
raise FileExistsError
|
||||||
|
await amakedirs(subtitle_path.parent, exist_ok=True)
|
||||||
|
await ass.make_ass_file_danmakus_protobuf(
|
||||||
|
v,
|
||||||
|
page_id,
|
||||||
|
str(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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_nfo(nfo_path: Path, *, obj: Model, mode: NfoMode) -> None:
|
||||||
|
"""指定 nfo 路径、对象和模式,将对应的 nfo 信息写入到文件"""
|
||||||
|
if await aexists(nfo_path):
|
||||||
|
# 目标 nfo 已经存在,忽略掉
|
||||||
|
raise FileExistsError
|
||||||
|
await amakedirs(nfo_path.parent, exist_ok=True)
|
||||||
|
# 根据不同的模式,生成不同的 nfo
|
||||||
|
nfo: NfoBase = None
|
||||||
|
match obj, mode:
|
||||||
|
case FavoriteItem(), NfoMode.MOVIE:
|
||||||
|
nfo = MovieInfo.from_favorite_item(obj)
|
||||||
|
case FavoriteItem(), NfoMode.TVSHOW:
|
||||||
|
nfo = TVShowInfo.from_favorite_item(obj)
|
||||||
|
case FavoriteItemPage(), NfoMode.EPISODE:
|
||||||
|
nfo = EpisodeInfo.from_favorite_item_page(obj)
|
||||||
|
case Upper(), NfoMode.UPPER:
|
||||||
|
nfo = UpperInfo.from_upper(obj)
|
||||||
|
case _:
|
||||||
|
raise ValueError
|
||||||
|
await nfo.to_file(nfo_path)
|
||||||
|
|||||||
+3
-1
@@ -25,7 +25,7 @@ ruff = "0.2.2"
|
|||||||
line-length = 100
|
line-length = 100
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 120
|
||||||
lint.select = [
|
lint.select = [
|
||||||
"F", # https://beta.ruff.rs/docs/rules/#pyflakes-f
|
"F", # https://beta.ruff.rs/docs/rules/#pyflakes-f
|
||||||
"E",
|
"E",
|
||||||
@@ -53,6 +53,8 @@ lint.select = [
|
|||||||
lint.ignore = [
|
lint.ignore = [
|
||||||
"A003", # Class attribute `id` is shadowing a Python builtin
|
"A003", # Class attribute `id` is shadowing a Python builtin
|
||||||
]
|
]
|
||||||
|
lint.isort.split-on-trailing-comma = false
|
||||||
|
format.skip-magic-trailing-comma = true
|
||||||
exclude = ["migrations"]
|
exclude = ["migrations"]
|
||||||
|
|
||||||
[tool.aerich]
|
[tool.aerich]
|
||||||
|
|||||||
+2
-6
@@ -26,13 +26,9 @@ class Config(BaseModel):
|
|||||||
path_mapper: dict[int, str] = Field(default_factory=dict)
|
path_mapper: dict[int, str] = Field(default_factory=dict)
|
||||||
subtitle: SubtitleConfig = Field(default_factory=SubtitleConfig)
|
subtitle: SubtitleConfig = Field(default_factory=SubtitleConfig)
|
||||||
codec: list[VideoCodecs] = Field(
|
codec: list[VideoCodecs] = Field(
|
||||||
default_factory=lambda: [
|
default_factory=lambda: [VideoCodecs.AV1, VideoCodecs.AVC, VideoCodecs.HEV], min_length=1
|
||||||
VideoCodecs.AV1,
|
|
||||||
VideoCodecs.AVC,
|
|
||||||
VideoCodecs.HEV,
|
|
||||||
],
|
|
||||||
min_length=1,
|
|
||||||
)
|
)
|
||||||
|
paginated_video: bool = False
|
||||||
|
|
||||||
@field_validator("codec", mode="after")
|
@field_validator("codec", mode="after")
|
||||||
def codec_validator(cls, codecs: list[VideoCodecs]) -> list[VideoCodecs]:
|
def codec_validator(cls, codecs: list[VideoCodecs]) -> list[VideoCodecs]:
|
||||||
|
|||||||
@@ -27,9 +27,7 @@ async def amakedirs(path: Path, exist_ok=False) -> None:
|
|||||||
await makedirs(path, exist_ok=exist_ok)
|
await makedirs(path, exist_ok=exist_ok)
|
||||||
|
|
||||||
|
|
||||||
def aopen(
|
def aopen(path: Path, mode: str = "r", **kwargs) -> AiofilesContextManager[None, None, AsyncTextIOWrapper]:
|
||||||
path: Path, mode: str = "r", **kwargs
|
|
||||||
) -> AiofilesContextManager[None, None, AsyncTextIOWrapper]:
|
|
||||||
return aiofiles.open(path, mode, **kwargs)
|
return aiofiles.open(path, mode, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user