chore: move enums and objects to single file
This commit is contained in:
parent
1ca227b8be
commit
3c8a46d039
@ -6,10 +6,12 @@ from click import argument, command, confirm, option
|
||||
from rich.console import Console
|
||||
from rich.theme import Theme
|
||||
|
||||
from ncmlyrics.error import UnsupportLinkError
|
||||
|
||||
from .api import NCMApi, NCMTrack
|
||||
from .util import Link, LinkType, parseLink, pickOutput
|
||||
from .api import NCMApi
|
||||
from .enum import LinkType
|
||||
from .error import UnsupportedLinkError
|
||||
from .lrc import Lrc
|
||||
from .object import NCMTrack
|
||||
from .util import Link, parseLink, pickOutput
|
||||
|
||||
NCMLyricsAppTheme = Theme(
|
||||
{
|
||||
@ -136,7 +138,7 @@ def main(outputs: list[Path], exist: bool, overwrite: bool, quiet: bool, links:
|
||||
if ncmlyrics.isPureMusic:
|
||||
console.print(f"曲目 {track.name} 为纯音乐, 跳过此曲目")
|
||||
else:
|
||||
ncmlyrics.lrc().saveAs(path)
|
||||
Lrc.fromNCMLyrics(ncmlyrics).saveAs(path)
|
||||
console.print(f"--> {str(path)}")
|
||||
|
||||
api.saveCookies()
|
||||
|
@ -1,7 +1,7 @@
|
||||
from dataclasses import dataclass
|
||||
from http.cookiejar import LoadError, MozillaCookieJar
|
||||
from json import dumps as dumpJson, JSONDecodeError
|
||||
from typing import Any, Iterable, Self
|
||||
from json import dumps as dumpJson
|
||||
from typing import Iterable
|
||||
|
||||
from httpx import Client as HttpXClient
|
||||
from httpx import Request as HttpXRequest
|
||||
@ -9,12 +9,10 @@ from httpx import Response as HttpXResponse
|
||||
|
||||
from .constant import CONFIG_API_DETAIL_TRACK_PER_REQUEST, NCM_API_BASE_URL, PLATFORM
|
||||
from .error import (
|
||||
NCMApiResponseParseError,
|
||||
NCMApiRequestError,
|
||||
NCMApiRetryLimitExceededError,
|
||||
UnsupportedPureMusicTrackError,
|
||||
)
|
||||
from .lrc import Lrc, LrcType
|
||||
from .object import NCMAlbum, NCMLyrics, NCMPlaylist, NCMTrack
|
||||
|
||||
REQUEST_HEADERS = {
|
||||
"Accept": "application/json",
|
||||
@ -24,171 +22,6 @@ REQUEST_HEADERS = {
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class NCMTrack:
|
||||
id: int
|
||||
name: str
|
||||
artists: list[str]
|
||||
|
||||
@classmethod
|
||||
def fromApi(cls, response: HttpXResponse) -> list[Self]:
|
||||
try:
|
||||
data: dict = response.json()
|
||||
except JSONDecodeError:
|
||||
raise NCMApiResponseParseError("无法以预期的 Json 格式解析响应")
|
||||
|
||||
if data.get("code") != 200:
|
||||
raise NCMApiResponseParseError(f"响应码不为 200: {data["code"]}")
|
||||
|
||||
data = data.get("songs")
|
||||
if data is None:
|
||||
raise NCMApiResponseParseError("不存在单曲对应的结构")
|
||||
|
||||
result = []
|
||||
|
||||
for track in data:
|
||||
result.append(cls.fromData(track))
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def fromData(cls, data: dict) -> Self:
|
||||
try:
|
||||
return cls(
|
||||
id=data["id"],
|
||||
name=data["name"],
|
||||
artists=[artist["name"] for artist in data["ar"]],
|
||||
)
|
||||
except KeyError as e:
|
||||
raise NCMApiResponseParseError(f"需要的键不存在: {e}")
|
||||
|
||||
def link(self) -> str:
|
||||
return f"https://music.163.com/song?id={self.id}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class NCMAlbum:
|
||||
id: int
|
||||
name: str
|
||||
tracks: list[NCMTrack]
|
||||
|
||||
@classmethod
|
||||
def fromApi(cls, response: HttpXResponse) -> Self:
|
||||
try:
|
||||
data: dict = response.json()
|
||||
except JSONDecodeError:
|
||||
raise NCMApiResponseParseError("无法以预期的 Json 格式解析响应")
|
||||
|
||||
if data.get("code") != 200:
|
||||
raise NCMApiResponseParseError(f"响应码不为 200: {data["code"]}")
|
||||
|
||||
album = data.get("album")
|
||||
if album is None:
|
||||
raise NCMApiResponseParseError("不存在专辑对应的结构")
|
||||
|
||||
try:
|
||||
return cls(
|
||||
id=album["id"],
|
||||
name=album["name"],
|
||||
tracks=[NCMTrack.fromData(track) for track in data["songs"]],
|
||||
)
|
||||
except KeyError as e:
|
||||
raise NCMApiResponseParseError(f"需要的键不存在: {e}")
|
||||
|
||||
def link(self) -> str:
|
||||
return f"https://music.163.com/album?id={self.id}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class NCMPlaylist:
|
||||
id: int
|
||||
name: str
|
||||
tracks: list[NCMTrack]
|
||||
trackIds: list[int]
|
||||
|
||||
@classmethod
|
||||
def fromApi(cls, response: HttpXResponse) -> Self:
|
||||
try:
|
||||
data: dict = response.json()
|
||||
except JSONDecodeError:
|
||||
raise NCMApiResponseParseError("无法以预期的 Json 格式解析响应")
|
||||
|
||||
if data.get("code") != 200:
|
||||
raise NCMApiResponseParseError(f"响应码不为 200: {data["code"]}")
|
||||
|
||||
playlist = data.get("playlist")
|
||||
if playlist is None:
|
||||
raise NCMApiResponseParseError("不存在歌单对应的结构")
|
||||
|
||||
try:
|
||||
tracks: list[NCMTrack] = []
|
||||
trackIds: list[int] = [track["id"] for track in playlist["trackIds"]]
|
||||
|
||||
for track in playlist["tracks"]:
|
||||
parsedTrack = NCMTrack.fromData(track)
|
||||
trackIds.remove(parsedTrack.id)
|
||||
tracks.append(parsedTrack)
|
||||
|
||||
return cls(
|
||||
id=playlist["id"],
|
||||
name=playlist["name"],
|
||||
tracks=tracks,
|
||||
trackIds=trackIds,
|
||||
)
|
||||
except KeyError as e:
|
||||
raise NCMApiResponseParseError(f"需要的键不存在: {e}")
|
||||
|
||||
def link(self) -> str:
|
||||
return f"https://music.163.com/playlist?id={self.id}"
|
||||
|
||||
def fillDetailsOfTracks(self, api) -> None:
|
||||
self.tracks.extend(api.getDetailsForTracks(self.trackIds))
|
||||
self.trackIds.clear()
|
||||
|
||||
|
||||
@dataclass
|
||||
class NCMLyrics:
|
||||
id: int | None
|
||||
isPureMusic: bool
|
||||
data: Any | None
|
||||
|
||||
@classmethod
|
||||
def fromApi(cls, response: HttpXResponse) -> Self:
|
||||
try:
|
||||
data: dict = response.json()
|
||||
except JSONDecodeError:
|
||||
raise NCMApiResponseParseError("无法以预期的 Json 格式解析响应")
|
||||
|
||||
if data.get("code") != 200:
|
||||
raise NCMApiResponseParseError(f"响应码不为 200: {data["code"]}")
|
||||
|
||||
if data.get("pureMusic") is True:
|
||||
return cls(id=None, isPureMusic=True, data=None)
|
||||
|
||||
return cls(id=None, isPureMusic=False, data=data)
|
||||
|
||||
def withId(self, id: int) -> Self:
|
||||
self.id = id
|
||||
return self
|
||||
|
||||
def lrc(self) -> Lrc:
|
||||
if self.isPureMusic:
|
||||
raise UnsupportedPureMusicTrackError
|
||||
|
||||
result = Lrc()
|
||||
|
||||
for lrcType in LrcType:
|
||||
try:
|
||||
lrcStr = self.data[lrcType.value]["lyric"]
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
if lrcStr != "":
|
||||
result.serializeLyricString(lrcType, lrcStr)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class NCMApi:
|
||||
def __init__(self, http2: bool = True) -> None:
|
||||
self._cookiePath = PLATFORM.user_config_path / "cookies.txt"
|
||||
|
34
src/ncmlyrics/enum.py
Normal file
34
src/ncmlyrics/enum.py
Normal file
@ -0,0 +1,34 @@
|
||||
from enum import Enum, auto
|
||||
|
||||
__all__ = ["LrcType", "LrcMetaType", "LinkType"]
|
||||
|
||||
|
||||
class LrcType(Enum):
|
||||
Origin = auto()
|
||||
Translation = auto()
|
||||
Romaji = auto()
|
||||
|
||||
def preety(self) -> str:
|
||||
match self:
|
||||
case LrcType.Origin:
|
||||
return "源"
|
||||
case LrcType.Translation:
|
||||
return "译"
|
||||
case LrcType.Romaji:
|
||||
return "音"
|
||||
|
||||
|
||||
class LrcMetaType(Enum):
|
||||
Title = "ti"
|
||||
Artist = "ar"
|
||||
Album = "al"
|
||||
Author = "au"
|
||||
Length = "length"
|
||||
LrcAuthor = "by"
|
||||
Offset = "offset"
|
||||
|
||||
|
||||
class LinkType(Enum):
|
||||
Song = auto()
|
||||
Album = auto()
|
||||
Playlist = auto()
|
@ -13,14 +13,14 @@ class NCMApiRequestError(NCMApiError, RequestError):
|
||||
"""请求网易云音乐 API 时出现错误"""
|
||||
|
||||
|
||||
class NCMApiResponseParseError(NCMApiError):
|
||||
"""解析网易云音乐 API 返回的数据时出现错误"""
|
||||
|
||||
|
||||
class NCMApiRetryLimitExceededError(NCMApiError):
|
||||
"""请求网易云音乐 API 时错误次数超过重试次数上限"""
|
||||
|
||||
|
||||
class ObjectParseError(NCMLyricsAppError):
|
||||
"""解析网易云音乐 API 返回的数据时出现错误"""
|
||||
|
||||
|
||||
class ParseLinkError(NCMLyricsAppError):
|
||||
"""无法解析此分享链接"""
|
||||
|
||||
|
@ -1,10 +1,16 @@
|
||||
from enum import Enum
|
||||
from json import JSONDecodeError
|
||||
from json import loads as loadJson
|
||||
from pathlib import Path
|
||||
from re import Match
|
||||
from re import compile as reCompile
|
||||
from typing import Generator
|
||||
from typing import Generator, Iterable, Self
|
||||
|
||||
from .constant import CONFIG_LRC_AUTO_MERGE, CONFIG_LRC_AUTO_MERGE_OFFSET
|
||||
from .enum import LrcMetaType, LrcType
|
||||
from .error import UnsupportedPureMusicTrackError
|
||||
from .object import NCMLyrics
|
||||
|
||||
__all__ = ["LrcType", "LrcMetaType", "Lrc"]
|
||||
|
||||
LRC_RE_COMMIT = reCompile(r"^\s*#")
|
||||
LRC_RE_META = reCompile(r"^\s*\[(?P<type>ti|ar|al|au|length|by|offset):\s*(?P<content>.+?)\s*\]\s*$")
|
||||
@ -13,60 +19,55 @@ LRC_RE_LYRIC = reCompile(r"^\s*(?P<timelabels>(?:\s*\[\d{1,2}:\d{1,2}(?:\.\d{1,3
|
||||
LRC_RE_LYRIC_TIMELABEL = reCompile(r"\[(?P<minutes>\d{1,2}):(?P<seconds>\d{1,2}(?:\.\d{1,3})?)\]")
|
||||
|
||||
|
||||
class LrcType(Enum):
|
||||
Origin = "lrc"
|
||||
Translation = "tlyric"
|
||||
Romaji = "romalrc"
|
||||
|
||||
def preety(self) -> str:
|
||||
match self:
|
||||
case LrcType.Origin:
|
||||
return "源"
|
||||
case LrcType.Translation:
|
||||
return "译"
|
||||
case LrcType.Romaji:
|
||||
return "音"
|
||||
|
||||
|
||||
class LrcMetaType(Enum):
|
||||
Title = "ti"
|
||||
Artist = "ar"
|
||||
Album = "al"
|
||||
Author = "au"
|
||||
Length = "length"
|
||||
LrcAuthor = "by"
|
||||
Offset = "offset"
|
||||
|
||||
|
||||
class Lrc:
|
||||
def __init__(self) -> None:
|
||||
# metaType: lrcType: metaContent
|
||||
self.metadata: dict[LrcMetaType, dict[LrcType, str]] = {}
|
||||
|
||||
# timestamp: lrcType: lrcContent
|
||||
self.lyrics: dict[int, dict[LrcType, str]] = {}
|
||||
# timestamp: lrcType/String: lrcContent
|
||||
self.lyrics: dict[int, dict[LrcType | str, str]] = {}
|
||||
|
||||
def serializeLyricString(self, lrcType: LrcType, lrcStr: str) -> None:
|
||||
for line in lrcStr.splitlines():
|
||||
@classmethod
|
||||
def fromNCMLyrics(cls, lyrics: NCMLyrics) -> Self:
|
||||
if lyrics.isPureMusic:
|
||||
raise UnsupportedPureMusicTrackError
|
||||
|
||||
result = cls()
|
||||
|
||||
for lrcType in LrcType:
|
||||
lrcStr = lyrics.get(lrcType)
|
||||
if lrcStr:
|
||||
result.serializeLyricFile(lrcType, lrcStr)
|
||||
|
||||
return result
|
||||
|
||||
def serializeLyricFile(self, lrcType: LrcType, lrcFile: str) -> None:
|
||||
self.serializeLyricRows(lrcType, lrcFile.splitlines())
|
||||
|
||||
def serializeLyricRows(self, lrcType: LrcType, lrcRows: Iterable[str]) -> None:
|
||||
for row in lrcRows:
|
||||
self.serializeLyricRow(lrcType, row)
|
||||
|
||||
def serializeLyricRow(self, lrcType: LrcType, lrcRow: str) -> None:
|
||||
# Skip commit lines
|
||||
if LRC_RE_COMMIT.match(line) is not None:
|
||||
continue
|
||||
if LRC_RE_COMMIT.match(lrcRow) is not None:
|
||||
return
|
||||
|
||||
# Skip NCM spical metadata lines
|
||||
if LRC_RE_META_NCM_SPICAL.match(line) is not None:
|
||||
continue
|
||||
if LRC_RE_META_NCM_SPICAL.match(lrcRow) is not None:
|
||||
return
|
||||
|
||||
matchedMetaDataRow = LRC_RE_META.match(line)
|
||||
matchedMetaDataRow = LRC_RE_META.match(lrcRow)
|
||||
if matchedMetaDataRow is not None:
|
||||
self.appendMatchedMetaDataRow(lrcType, matchedMetaDataRow)
|
||||
continue
|
||||
return
|
||||
|
||||
matchedLyricRow = LRC_RE_LYRIC.match(line)
|
||||
matchedLyricRow = LRC_RE_LYRIC.match(lrcRow)
|
||||
if matchedLyricRow is not None:
|
||||
self.appendMatchedLyricRow(lrcType, matchedLyricRow)
|
||||
continue
|
||||
return
|
||||
|
||||
def appendLyric(self, lrcType: LrcType, timestamps: list[int], lyric: str):
|
||||
def appendLyric(self, lrcType: LrcType, timestamps: Iterable[int], lyric: str):
|
||||
for timestamp in timestamps:
|
||||
if timestamp in self.lyrics:
|
||||
self.lyrics[timestamp][lrcType] = lyric
|
||||
|
181
src/ncmlyrics/object.py
Normal file
181
src/ncmlyrics/object.py
Normal file
@ -0,0 +1,181 @@
|
||||
from dataclasses import dataclass
|
||||
from json import JSONDecodeError
|
||||
from typing import Self
|
||||
|
||||
from httpx import Response
|
||||
|
||||
from .enum import LrcType
|
||||
from .error import ObjectParseError
|
||||
|
||||
__all__ = ["NCMTrack", "NCMAlbum", "NCMPlaylist", "NCMLyrics"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class NCMTrack:
|
||||
id: int
|
||||
name: str
|
||||
artists: list[str]
|
||||
|
||||
@classmethod
|
||||
def fromApi(cls, response: Response) -> list[Self]:
|
||||
try:
|
||||
data: dict = response.json()
|
||||
except JSONDecodeError:
|
||||
raise ObjectParseError("无法以预期的 Json 格式解析响应")
|
||||
|
||||
print(data)
|
||||
|
||||
if data.get("code") != 200:
|
||||
raise ObjectParseError(f"响应码不为 200: {data["code"]}")
|
||||
|
||||
data = data.get("songs")
|
||||
if data is None:
|
||||
raise ObjectParseError("不存在单曲对应的结构")
|
||||
|
||||
result = []
|
||||
|
||||
for track in data:
|
||||
result.append(cls.fromData(track))
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def fromData(cls, data: dict) -> Self:
|
||||
try:
|
||||
return cls(
|
||||
id=data["id"],
|
||||
name=data["name"],
|
||||
artists=[artist["name"] for artist in data["ar"]],
|
||||
)
|
||||
except KeyError as e:
|
||||
raise ObjectParseError(f"需要的键不存在: {e}")
|
||||
|
||||
def link(self) -> str:
|
||||
return f"https://music.163.com/song?id={self.id}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class NCMAlbum:
|
||||
id: int
|
||||
name: str
|
||||
tracks: list[NCMTrack]
|
||||
|
||||
@classmethod
|
||||
def fromApi(cls, response: Response) -> Self:
|
||||
try:
|
||||
data: dict = response.json()
|
||||
except JSONDecodeError:
|
||||
raise ObjectParseError("无法以预期的 Json 格式解析响应")
|
||||
|
||||
if data.get("code") != 200:
|
||||
raise ObjectParseError(f"响应码不为 200: {data["code"]}")
|
||||
|
||||
album = data.get("album")
|
||||
if album is None:
|
||||
raise ObjectParseError("不存在专辑对应的结构")
|
||||
|
||||
try:
|
||||
return cls(
|
||||
id=album["id"],
|
||||
name=album["name"],
|
||||
tracks=[NCMTrack.fromData(track) for track in data["songs"]],
|
||||
)
|
||||
except KeyError as e:
|
||||
raise ObjectParseError(f"需要的键不存在: {e}")
|
||||
|
||||
def link(self) -> str:
|
||||
return f"https://music.163.com/album?id={self.id}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class NCMPlaylist:
|
||||
id: int
|
||||
name: str
|
||||
tracks: list[NCMTrack]
|
||||
trackIds: list[int]
|
||||
|
||||
@classmethod
|
||||
def fromApi(cls, response: Response) -> Self:
|
||||
try:
|
||||
data: dict = response.json()
|
||||
except JSONDecodeError:
|
||||
raise ObjectParseError("无法以预期的 Json 格式解析响应")
|
||||
|
||||
if data.get("code") != 200:
|
||||
raise ObjectParseError(f"响应码不为 200: {data["code"]}")
|
||||
|
||||
playlist = data.get("playlist")
|
||||
if playlist is None:
|
||||
raise ObjectParseError("不存在歌单对应的结构")
|
||||
|
||||
try:
|
||||
tracks: list[NCMTrack] = []
|
||||
trackIds: list[int] = [track["id"] for track in playlist["trackIds"]]
|
||||
|
||||
for track in playlist["tracks"]:
|
||||
parsedTrack = NCMTrack.fromData(track)
|
||||
trackIds.remove(parsedTrack.id)
|
||||
tracks.append(parsedTrack)
|
||||
|
||||
return cls(
|
||||
id=playlist["id"],
|
||||
name=playlist["name"],
|
||||
tracks=tracks,
|
||||
trackIds=trackIds,
|
||||
)
|
||||
except KeyError as e:
|
||||
raise ObjectParseError(f"需要的键不存在: {e}")
|
||||
|
||||
def link(self) -> str:
|
||||
return f"https://music.163.com/playlist?id={self.id}"
|
||||
|
||||
def fillDetailsOfTracks(self, api) -> None:
|
||||
self.tracks.extend(api.getDetailsForTracks(self.trackIds))
|
||||
self.trackIds.clear()
|
||||
|
||||
|
||||
@dataclass
|
||||
class NCMLyrics:
|
||||
id: int | None
|
||||
isPureMusic: bool
|
||||
lyrics: dict[LrcType, str]
|
||||
|
||||
@classmethod
|
||||
def fromApi(cls, response: Response) -> Self:
|
||||
try:
|
||||
data: dict = response.json()
|
||||
except JSONDecodeError:
|
||||
raise ObjectParseError("无法以预期的 Json 格式解析响应")
|
||||
|
||||
if data.get("code") != 200:
|
||||
raise ObjectParseError(f"响应码不为 200: {data["code"]}")
|
||||
|
||||
lyrics: dict[LrcType, str] = {}
|
||||
|
||||
try:
|
||||
lyrics[LrcType.Origin] = data["lrc"]["lyric"]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
try:
|
||||
lyrics[LrcType.Translation] = data["tlyric"]["lyric"]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
try:
|
||||
lyrics[LrcType.Romaji] = data["romalrc"]["lyric"]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
return cls(
|
||||
id=None,
|
||||
isPureMusic=data.get("pureMusic", False),
|
||||
lyrics=lyrics,
|
||||
)
|
||||
|
||||
def withId(self, id: int) -> Self:
|
||||
self.id = id
|
||||
return self
|
||||
|
||||
def get(self, type: LrcType) -> str | None:
|
||||
return self.lyrics.get(type, None)
|
@ -1,5 +1,4 @@
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, auto
|
||||
from pathlib import Path
|
||||
from re import compile as reCompile
|
||||
from urllib.parse import parse_qs as parseQuery
|
||||
@ -7,20 +6,15 @@ from urllib.parse import urlparse as parseUrl
|
||||
|
||||
from httpx import get as httpGet
|
||||
|
||||
from .api import NCMTrack
|
||||
from .error import ParseLinkError, UnsupportLinkError
|
||||
from .enum import LinkType
|
||||
from .error import ParseLinkError, UnsupportedLinkError
|
||||
from .object import NCMTrack
|
||||
|
||||
RE_ANDROID_ALBUM_SHARE_LINK_PATH = reCompile(r"^/album/(?P<id>\d*)/?$")
|
||||
RE_SAFE_FILENAME = reCompile(r"\*{2,}")
|
||||
TRANSLATER_SAFE_FILENAME = str.maketrans({i: 0x2A for i in ("<", ">", ":", '"', "/", "\\", "|", "?")})
|
||||
|
||||
|
||||
class LinkType(Enum):
|
||||
Song = auto()
|
||||
Album = auto()
|
||||
Playlist = auto()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Link:
|
||||
type: LinkType
|
||||
|
Loading…
x
Reference in New Issue
Block a user