diff --git a/README.md b/README.md index 442cbe9..02be9b5 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,19 @@ Structured Markdown notes with timestamps NoteForge downloads subtitles only. It does not download the video or audio. +## Media service and credentials + +`noteforge.media.MediaService` is the sole media boundary for YouTube and +Bilibili. It exposes metadata, formats, playlists, subtitles, audio, and video +without leaking yt-dlp options. yt-dlp runs in isolated worker processes, while +downloaded assets live in expiring leases and are removed when `MediaAsset` is +closed. Persistence requires an explicit `export_to()` call. + +Browser credentials are handled by `CookieService`. A task receives a private, +short-lived Cookie lease that is deleted by default. Opt-in retained credentials +are AEAD-encrypted and their master key is stored in the operating-system +keyring; plaintext Cookie files are never retained. + --- ## Capabilities @@ -393,12 +406,10 @@ Project structure: noteforge/ ├── src/noteforge/ │ ├── cli/ # Typer commands -│ ├── collector/ # Source inspection and Bilibili collection -│ ├── subtitle/ # Subtitle selection, parsing, and normalization +│ ├── media/ # Unified media, Cookie, platform, and worker service │ ├── knowledge/ # Chunking, semantic analysis, and extraction │ ├── llm/ # OpenAI-compatible, Anthropic Messages, and Ollama adapters -│ ├── document/ # Learning-document construction -│ ├── renderer/ # Markdown rendering and writing +│ ├── document/ # Learning-document construction and Markdown rendering │ └── core/ # End-to-end pipeline ├── tests/ ├── .github/workflows/publish.yml diff --git a/README.zh-CN.md b/README.zh-CN.md index 7af5191..a91ebac 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -30,11 +30,15 @@ ## 视频采集配置 -Media Extractor 统一支持 Bilibili 与 YouTube。复制 -`config.example.yaml` 为 `config.yaml` 后,可分别配置代理、缓存、下载目录和 -Cookie。推荐同时设置 `cookie_file` 与 `cookies_from_browser`:首次运行从已登录 -浏览器导入 Cookie,之后只复用持久文件,不会反复读取浏览器 Cookie 或提示系统 -密码。`config.yaml`、`.noteforge/` 与 `.cache/` 已被 Git 忽略,请勿提交 Cookie。 +`noteforge.media.MediaService` 是 Bilibili 与 YouTube 的唯一媒体入口,统一提供 +元数据、格式、播放列表、字幕、音频和视频 API。yt-dlp 在独立 Worker 进程中执行, +调用方不会接触其参数或下载路径。媒体默认保存在有 TTL 的临时租约中,退出 +`MediaAsset` 上下文后立即删除;只有显式调用 `export_to()` 才会持久化。 + +浏览器身份由独立 `CookieService` 管理。每个任务只获得权限为 `0600` 的短期 +Cookie 租约,任务结束默认销毁。用户显式选择 `CookiePersistence.RETAIN` 时, +目标平台 Cookie 使用 AEAD 加密保存,主密钥进入系统 Keyring;不会持久化明文 +`cookies.txt`。`config.yaml`、`.noteforge/` 与 `.cache/` 已被 Git 忽略。 字幕 fallback 顺序为人工字幕、自动字幕、可选的音频转录器;媒体层目前可解析 VTT、SRT、ASS 和 JSON3,并为 Whisper 实现保留了 `AudioTranscriber` 接口。 @@ -411,12 +415,10 @@ GitHub 中不需要保存长期 PyPI Token。 noteforge/ ├── src/noteforge/ │ ├── cli/ # Typer 命令 -│ ├── collector/ # 来源检查与 B 站采集 -│ ├── subtitle/ # 字幕选择、解析与规范化 +│ ├── media/ # 统一媒体、Cookie、平台适配与 Worker 服务 │ ├── knowledge/ # 分块、语义分析与知识提取 │ ├── llm/ # OpenAI-compatible、Anthropic Messages 和 Ollama 适配器 -│ ├── document/ # 学习文档构建 -│ ├── renderer/ # Markdown 渲染与写入 +│ ├── document/ # 学习文档构建与 Markdown 渲染 │ └── core/ # 端到端流水线 ├── tests/ ├── .github/workflows/publish.yml diff --git a/config.example.yaml b/config.example.yaml index 36ebcc7..2fb5629 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,13 +1,11 @@ -# 复制为 config.yaml。Cookie 文件属于敏感信息,请勿提交到版本库。 +# 复制为 config.yaml。媒体与 Cookie 均由租约管理,不配置明文 Cookie 文件。 extractor: cache_path: .cache/noteforge/media - download_path: .cache/noteforge/downloads + runtime_path: .cache/noteforge/runtime + credential_vault_path: .noteforge/credentials + worker_count: 2 proxy: youtube: - # 首次运行时,从已登录的浏览器导入 Cookie 到此文件。 - # 后续运行只使用持久化文件,不会再次提示授权。 - cookie_file: .noteforge/cookies/youtube.txt - cookies_from_browser: chrome + proxy: bilibili: - cookie_file: .noteforge/cookies/bilibili.txt - cookies_from_browser: chrome + proxy: diff --git a/pyproject.toml b/pyproject.toml index 9328a06..87c8616 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,9 @@ classifiers = [ "Topic :: Text Processing :: Markup :: Markdown", ] dependencies = [ + "cryptography>=45,<48", "httpx>=0.28,<1", + "keyring>=25,<27", "rich>=13,<15", "typer>=0.12,<1", "yt-dlp[curl-cffi]>=2026.7.4", diff --git a/src/noteforge/cli/commands/doctor.py b/src/noteforge/cli/commands/doctor.py index 33c6537..2110c01 100644 --- a/src/noteforge/cli/commands/doctor.py +++ b/src/noteforge/cli/commands/doctor.py @@ -6,11 +6,11 @@ import typer from noteforge.cli.ui import StatusUI -from noteforge.collector import discover_video -from noteforge.collector import source as inspection from noteforge.config import LLMSettings, llm_api_format_label from noteforge.exceptions import CollectionError, LLMConfigurationError from noteforge.llm import LLMMessage, create_llm_client +from noteforge.media import discover_video +from noteforge.media import source as inspection from noteforge.media.models import Subtitle, VideoResource diff --git a/src/noteforge/cli/commands/generate.py b/src/noteforge/cli/commands/generate.py index 72ca195..829fbb2 100644 --- a/src/noteforge/cli/commands/generate.py +++ b/src/noteforge/cli/commands/generate.py @@ -8,13 +8,13 @@ from noteforge.cli.commands.configure import load_configured_llm_settings from noteforge.cli.renderer import PipelineRenderer from noteforge.cli.ui import StatusUI -from noteforge.collector import collect_video -from noteforge.collector import source as inspection from noteforge.config import LLMSettings, llm_api_format_label from noteforge.core import NoteGenerationPipeline from noteforge.core.events import compose_event_handlers from noteforge.exceptions import NoteForgeError, PipelineExecutionError from noteforge.llm import create_llm_client +from noteforge.media import collect_video +from noteforge.media import source as inspection from noteforge.media.models import VideoResource from noteforge.run import RunRecorder diff --git a/src/noteforge/cli/commands/inspect.py b/src/noteforge/cli/commands/inspect.py index 50b3ba3..dc3a591 100644 --- a/src/noteforge/cli/commands/inspect.py +++ b/src/noteforge/cli/commands/inspect.py @@ -7,9 +7,9 @@ import typer from noteforge.cli.serialization import subtitle_debug_output -from noteforge.collector import collect_video -from noteforge.collector import source as inspection from noteforge.exceptions import CollectionError, SubtitleError +from noteforge.media import collect_video +from noteforge.media import source as inspection def inspect( diff --git a/src/noteforge/collector/__init__.py b/src/noteforge/collector/__init__.py deleted file mode 100644 index 8bf885b..0000000 --- a/src/noteforge/collector/__init__.py +++ /dev/null @@ -1,24 +0,0 @@ -"""视频资源采集应用层。""" - -from noteforge.collector.factory import ( - collect_video, - create_video_collector, - discover_video, -) -from noteforge.collector.platforms import BilibiliVideoCollector, YouTubeCollector -from noteforge.collector.source import ( - InspectionPlatform, - InspectionResult, - inspect_source, -) - -__all__ = [ - "BilibiliVideoCollector", - "YouTubeCollector", - "InspectionPlatform", - "InspectionResult", - "create_video_collector", - "collect_video", - "discover_video", - "inspect_source", -] diff --git a/src/noteforge/collector/factory.py b/src/noteforge/collector/factory.py deleted file mode 100644 index 72786f1..0000000 --- a/src/noteforge/collector/factory.py +++ /dev/null @@ -1,83 +0,0 @@ -from pathlib import Path - -from noteforge.collector.platforms import ( - BilibiliVideoCollector, - PlatformCollector, - YouTubeCollector, -) -from noteforge.collector.source import inspect_source -from noteforge.exceptions import UnsupportedSourceError -from noteforge.media.config import ( - ExtractorConfig, - PlatformConfig, - load_extractor_config, -) -from noteforge.media.models import VideoResource - - -def create_video_collector( - source: str, config: ExtractorConfig | None = None -) -> PlatformCollector: - for collector_type in (BilibiliVideoCollector, YouTubeCollector): - collector = collector_type(config) - if collector.supports(source): - return collector - raise UnsupportedSourceError(f"不支持的视频 URL:{source}") - - -def _runtime_config( - source: str, - *, - cookies_from_browser: str | None, - cache_path: Path | None = None, -) -> ExtractorConfig: - """构造单次采集配置,显式命令行参数优先于配置文件。""" - - config = load_extractor_config() - platforms = dict(config.platforms) - if cookies_from_browser: - platform = inspect_source(source).platform.value - current = config.for_platform(platform) - platforms[platform] = PlatformConfig( - current.cookie_file, - cookies_from_browser, - current.proxy, - ) - return ExtractorConfig( - cache_path or config.cache_path, - config.download_path, - platforms, - ) - - -def collect_video( - source: str, - *, - cookies_from_browser: str | None = None, - subtitle_language: str | None = None, - subtitle_output_dir: Path | None = None, - page_number: int | None = None, -) -> VideoResource: - """选择平台采集器并提取完整视频资源。""" - - del page_number - config = _runtime_config( - source, - cookies_from_browser=cookies_from_browser, - cache_path=subtitle_output_dir, - ) - return create_video_collector(source, config).extract( - source, - subtitle_language=subtitle_language, - ) - - -def discover_video( - source: str, - *, - cookies_from_browser: str | None = None, -) -> VideoResource: - """选择平台采集器并发现视频元数据与字幕轨道。""" - - config = _runtime_config(source, cookies_from_browser=cookies_from_browser) - return create_video_collector(source, config).discover(source) diff --git a/src/noteforge/collector/platforms/__init__.py b/src/noteforge/collector/platforms/__init__.py deleted file mode 100644 index 88b684d..0000000 --- a/src/noteforge/collector/platforms/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""不同视频平台的采集策略。""" - -from noteforge.collector.platforms.base import PlatformCollector -from noteforge.collector.platforms.bilibili import BilibiliVideoCollector -from noteforge.collector.platforms.youtube import YouTubeCollector - -__all__ = ["BilibiliVideoCollector", "PlatformCollector", "YouTubeCollector"] diff --git a/src/noteforge/collector/platforms/base.py b/src/noteforge/collector/platforms/base.py deleted file mode 100644 index 72d27a4..0000000 --- a/src/noteforge/collector/platforms/base.py +++ /dev/null @@ -1,207 +0,0 @@ -"""不同视频平台采集器共享的应用流程。""" - -from abc import ABC, abstractmethod -from collections.abc import Mapping -from pathlib import Path -from typing import Any - -from noteforge.media.cache import MediaCache -from noteforge.media.config import ExtractorConfig -from noteforge.media.models import Subtitle, VideoMetadata, VideoResource -from noteforge.media.subtitle import SubtitleParser -from noteforge.media.transcriber import AudioTranscriber -from noteforge.media.ytdlp import YTDLPClient - - -class PlatformCollector(ABC): - platform: str - - def __init__( - self, - config: ExtractorConfig | None = None, - *, - transcriber: AudioTranscriber | None = None, - ) -> None: - self.config = config or ExtractorConfig() - self.cache = MediaCache(self.config.cache_path) - self.transcriber = transcriber - self.client = YTDLPClient( - self.config.for_platform(self.platform), - extra_options=self.ytdlp_options(), - ) - - @abstractmethod - def supports(self, source: str) -> bool: ... - - @abstractmethod - def normalize(self, source: str) -> str: ... - - def ytdlp_options(self) -> Mapping[str, Any]: - return {} - - def discover(self, source: str) -> VideoResource: - normalized = self.normalize(source) - info = self.client.extract_info(normalized) - metadata = self._metadata(info, normalized) - self.cache.save_metadata(metadata) - return VideoResource(metadata=metadata, subtitles=self._subtitles(info)) - - def extract( - self, - source: str, - *, - subtitle_language: str | None = None, - download_audio: bool = False, - download_video: bool = False, - ) -> VideoResource: - resource = self.discover(source) - metadata, subtitles = resource.metadata, resource.subtitles - transcript = self.cache.load_transcript(self.platform, metadata.id) or () - transcript_source = "cache" if transcript else None - if not transcript: - selected = self.select_subtitle(subtitles, subtitle_language) - if selected: - selected = self._download_subtitle( - metadata.webpage_url, metadata, selected - ) - transcript = SubtitleParser().parse(selected) - self.cache.save_transcript(metadata, transcript) - subtitles = (selected,) - transcript_source = ( - "automatic_subtitle" if selected.is_automatic else "manual_subtitle" - ) - audio_path = self._download_media(metadata, True) if download_audio else None - if not transcript and self.transcriber: - audio_path = audio_path or self._download_media(metadata, True) - transcript = self.transcriber.transcribe( - audio_path, language=subtitle_language - ) - self.cache.save_transcript(metadata, transcript) - transcript_source = "whisper" - video_path = self._download_media(metadata, False) if download_video else None - return VideoResource( - metadata, subtitles, transcript, audio_path, video_path, transcript_source - ) - - def select_subtitle( - self, subtitles: tuple[Subtitle, ...], preferred: str | None - ) -> Subtitle | None: - order = {"vtt": 0, "srt": 1, "ass": 2, "json3": 3} - candidates = [item for item in subtitles if item.format in order] - return ( - min( - candidates, - key=lambda item: ( - item.is_automatic, - 0 - if preferred and item.language.casefold() == preferred.casefold() - else 1, - order[item.format], - ), - ) - if candidates - else None - ) - - def _metadata(self, info: Mapping[str, Any], source: str) -> VideoMetadata: - video_id, title = info.get("id"), info.get("title") - if not isinstance(video_id, str) or not isinstance(title, str): - from noteforge.exceptions import RemoteCollectionError - - raise RemoteCollectionError("视频元数据缺少 id 或 title。") - duration = info.get("duration") - return VideoMetadata( - video_id, - title, - info.get("uploader") if isinstance(info.get("uploader"), str) else None, - int(duration) if isinstance(duration, (int, float)) else None, - info.get("thumbnail") if isinstance(info.get("thumbnail"), str) else None, - self.platform, - info.get("webpage_url") - if isinstance(info.get("webpage_url"), str) - else source, - info.get("description") - if isinstance(info.get("description"), str) - else None, - ) - - def _subtitles(self, info: Mapping[str, Any]) -> tuple[Subtitle, ...]: - result: list[Subtitle] = [] - for key, automatic in (("subtitles", False), ("automatic_captions", True)): - tracks = info.get(key) - if not isinstance(tracks, Mapping): - continue - for language, formats in tracks.items(): - if not isinstance(language, str) or not isinstance(formats, list): - continue - for item in formats: - if isinstance(item, Mapping) and isinstance(item.get("ext"), str): - result.append( - Subtitle( - language, - item["ext"].lower(), - content=item.get("data") - if isinstance(item.get("data"), str) - else None, - is_automatic=automatic - or language.casefold().startswith("ai-"), - ) - ) - return tuple(result) - - def _download_subtitle( - self, source: str, metadata: VideoMetadata, subtitle: Subtitle - ) -> Subtitle: - target_dir = self.cache.video_dir(self.platform, metadata.id) - if subtitle.content is not None: - path = target_dir / f"subtitle.{subtitle.format}" - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(subtitle.content, encoding="utf-8") - return Subtitle( - subtitle.language, - subtitle.format, - path, - subtitle.content, - subtitle.is_automatic, - ) - info = self.client.download_subtitle( - source, - language=subtitle.language, - subtitle_format=subtitle.format, - target_dir=target_dir, - ) - requested = info.get("requested_subtitles", {}) - item = ( - requested.get(subtitle.language, {}) - if isinstance(requested, Mapping) - else {} - ) - path = item.get("filepath") if isinstance(item, Mapping) else None - if not isinstance(path, str): - from noteforge.exceptions import RemoteCollectionError - - raise RemoteCollectionError("yt-dlp 未返回字幕文件路径。") - return Subtitle( - subtitle.language, - str(item.get("ext", subtitle.format)), - Path(path), - is_automatic=subtitle.is_automatic, - ) - - def _download_media(self, metadata: VideoMetadata, audio: bool) -> Path: - target = ( - self.config.download_path - / self.platform - / metadata.id - / ("audio" if audio else "video") - ) - info = self.client.download_media( - metadata.webpage_url, target_dir=target, audio_only=audio - ) - downloads = info.get("requested_downloads") - path = ( - Path(str(downloads[0].get("filepath", ""))) - if isinstance(downloads, list) and downloads - else Path(str(info.get("_filename", ""))) - ) - return path.with_suffix(".mp3") if audio else path diff --git a/src/noteforge/collector/platforms/bilibili.py b/src/noteforge/collector/platforms/bilibili.py deleted file mode 100644 index b26b3c9..0000000 --- a/src/noteforge/collector/platforms/bilibili.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Bilibili 平台采集器。""" - -from noteforge.collector.platforms.base import PlatformCollector -from noteforge.collector.source.inspection import InspectionPlatform, inspect_source -from noteforge.exceptions import UnsupportedSourceError -from noteforge.media.models import VideoPlatform - - -class BilibiliVideoCollector(PlatformCollector): - platform = VideoPlatform.BILIBILI.value - - def supports(self, source: str) -> bool: - return inspect_source(source).platform is InspectionPlatform.BILIBILI - - def normalize(self, source: str) -> str: - result = inspect_source(source) - if ( - result.platform is not InspectionPlatform.BILIBILI - or result.normalized_source is None - ): - raise UnsupportedSourceError(f"不支持的 Bilibili URL:{source}") - return result.normalized_source - - def ytdlp_options(self): - return { - "http_headers": { - "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", - "Referer": "https://www.bilibili.com/", - } - } diff --git a/src/noteforge/collector/platforms/youtube.py b/src/noteforge/collector/platforms/youtube.py deleted file mode 100644 index e519d6b..0000000 --- a/src/noteforge/collector/platforms/youtube.py +++ /dev/null @@ -1,22 +0,0 @@ -"""YouTube 平台应用层采集器。""" - -from noteforge.collector.platforms.base import PlatformCollector -from noteforge.collector.source.inspection import InspectionPlatform, inspect_source -from noteforge.exceptions import UnsupportedSourceError -from noteforge.media.models import VideoPlatform - - -class YouTubeCollector(PlatformCollector): - platform = VideoPlatform.YOUTUBE.value - - def supports(self, source: str) -> bool: - return inspect_source(source).platform is InspectionPlatform.YOUTUBE - - def normalize(self, source: str) -> str: - result = inspect_source(source) - if ( - result.platform is not InspectionPlatform.YOUTUBE - or result.normalized_source is None - ): - raise UnsupportedSourceError(f"不支持的 YouTube URL:{source}") - return result.normalized_source diff --git a/src/noteforge/collector/source/__init__.py b/src/noteforge/collector/source/__init__.py deleted file mode 100644 index ea74baf..0000000 --- a/src/noteforge/collector/source/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -"""视频来源识别与规范化。""" - -from noteforge.collector.source.inspection import ( - InspectionPlatform, - InspectionResult, - inspect_source, -) - -__all__ = ["InspectionPlatform", "InspectionResult", "inspect_source"] diff --git a/src/noteforge/collector/source/inspection.py b/src/noteforge/collector/source/inspection.py deleted file mode 100644 index 39f1d83..0000000 --- a/src/noteforge/collector/source/inspection.py +++ /dev/null @@ -1,86 +0,0 @@ -"""采集应用层的视频 URL 识别与规范化。""" - -import re -from dataclasses import dataclass -from enum import StrEnum -from urllib.parse import parse_qs, urlparse - -_BILIBILI_HOSTS = {"bilibili.com", "www.bilibili.com"} -_BVID_PATTERN = re.compile(r"BV[0-9A-Za-z]{10}") -_YOUTUBE_HOSTS = { - "m.youtube.com", - "music.youtube.com", - "www.youtube.com", - "youtube.com", -} -_YOUTUBE_EMBED_HOSTS = {"www.youtube-nocookie.com", "youtube-nocookie.com"} -_YOUTUBE_SHORT_HOSTS = {"www.youtu.be", "youtu.be"} -_YOUTUBE_VIDEO_ID_PATTERN = re.compile(r"[0-9A-Za-z_-]{11}") - - -class InspectionPlatform(StrEnum): - BILIBILI = "bilibili" - YOUTUBE = "youtube" - LOCAL = "local" - UNKNOWN = "unknown" - - -@dataclass(frozen=True, slots=True) -class InspectionResult: - original_source: str - platform: InspectionPlatform - source_id: str | None = None - normalized_source: str | None = None - page_number: int | None = None - requires_remote_resolution: bool = False - - -def inspect_source(source: str) -> InspectionResult: - original = source - cleaned = source.strip() - for character in ("?", "=", "&"): - cleaned = cleaned.replace(f"\\{character}", character) - parsed = urlparse(cleaned) - if parsed.scheme in {"http", "https"} and parsed.hostname in _BILIBILI_HOSTS: - parts = [part for part in parsed.path.split("/") if part] - if ( - len(parts) >= 2 - and parts[0] == "video" - and _BVID_PATTERN.fullmatch(parts[1]) - ): - page = 1 - try: - page = max(1, int(parse_qs(parsed.query).get("p", ["1"])[0])) - except ValueError: - pass - normalized = f"https://www.bilibili.com/video/{parts[1]}" - if page != 1: - normalized += f"?p={page}" - return InspectionResult( - original, InspectionPlatform.BILIBILI, parts[1], normalized, page - ) - - video_id: str | None = None - if parsed.scheme in {"http", "https"}: - parts = [part for part in parsed.path.split("/") if part] - if parsed.hostname in _YOUTUBE_SHORT_HOSTS and parts: - video_id = parts[0] - elif parsed.hostname in _YOUTUBE_HOSTS: - if parsed.path.rstrip("/") == "/watch": - video_id = parse_qs(parsed.query).get("v", [None])[0] - elif len(parts) >= 2 and parts[0] in {"embed", "live", "shorts", "v"}: - video_id = parts[1] - elif ( - parsed.hostname in _YOUTUBE_EMBED_HOSTS - and len(parts) >= 2 - and parts[0] == "embed" - ): - video_id = parts[1] - if video_id and _YOUTUBE_VIDEO_ID_PATTERN.fullmatch(video_id): - return InspectionResult( - original, - InspectionPlatform.YOUTUBE, - video_id, - f"https://www.youtube.com/watch?v={video_id}", - ) - return InspectionResult(original, InspectionPlatform.UNKNOWN) diff --git a/src/noteforge/core/pipeline.py b/src/noteforge/core/pipeline.py index 023b2f9..6ca3f14 100644 --- a/src/noteforge/core/pipeline.py +++ b/src/noteforge/core/pipeline.py @@ -9,15 +9,13 @@ from time import perf_counter from typing import Any, Protocol -from noteforge.collector import collect_video -from noteforge.collector import source as inspection from noteforge.core.events import ( EventHandler, PipelineEvent, PipelineStatus, null_event_handler, ) -from noteforge.document import generate_document +from noteforge.document import MarkdownRenderer, generate_document, write_markdown from noteforge.exceptions import ( NoteForgeError, PipelineErrorContext, @@ -40,8 +38,9 @@ LLMTool, LLMToolResponse, ) +from noteforge.media import collect_video +from noteforge.media import source as inspection from noteforge.media.models import VideoResource -from noteforge.renderer import MarkdownRenderer, write_markdown VideoCollector = Callable[..., VideoResource] diff --git a/src/noteforge/document/__init__.py b/src/noteforge/document/__init__.py index 8bf659c..f8be29f 100644 --- a/src/noteforge/document/__init__.py +++ b/src/noteforge/document/__init__.py @@ -3,10 +3,14 @@ from noteforge.document.builder import KnowledgeDocumentBuilder from noteforge.document.generator import generate_document from noteforge.document.models import DocumentSection, LearningDocument +from noteforge.document.renderers import MarkdownRenderer +from noteforge.document.writer import write_markdown __all__ = [ "DocumentSection", "KnowledgeDocumentBuilder", "LearningDocument", + "MarkdownRenderer", "generate_document", + "write_markdown", ] diff --git a/src/noteforge/document/renderers/__init__.py b/src/noteforge/document/renderers/__init__.py new file mode 100644 index 0000000..19a383d --- /dev/null +++ b/src/noteforge/document/renderers/__init__.py @@ -0,0 +1,5 @@ +"""学习文档的不同输出格式渲染器。""" + +from noteforge.document.renderers.markdown import MarkdownRenderer + +__all__ = ["MarkdownRenderer"] diff --git a/src/noteforge/renderer/markdown.py b/src/noteforge/document/renderers/markdown.py similarity index 97% rename from src/noteforge/renderer/markdown.py rename to src/noteforge/document/renderers/markdown.py index 1dc5d42..c8156b4 100644 --- a/src/noteforge/renderer/markdown.py +++ b/src/noteforge/document/renderers/markdown.py @@ -2,7 +2,7 @@ import re -from noteforge.document import LearningDocument +from noteforge.document.models import LearningDocument from noteforge.knowledge.extraction import KnowledgePoint _MARKDOWN_SPECIAL_CHARACTERS = re.compile(r"([\\`*_{}\[\]()#+.!|>\-])") diff --git a/src/noteforge/renderer/writer.py b/src/noteforge/document/writer.py similarity index 93% rename from src/noteforge/renderer/writer.py rename to src/noteforge/document/writer.py index b672c07..9a2afb3 100644 --- a/src/noteforge/renderer/writer.py +++ b/src/noteforge/document/writer.py @@ -1,4 +1,4 @@ -"""Markdown 文件输出工具。""" +"""学习文档文件输出工具。""" from pathlib import Path diff --git a/src/noteforge/media/__init__.py b/src/noteforge/media/__init__.py index 9a7afe8..6b02d25 100644 --- a/src/noteforge/media/__init__.py +++ b/src/noteforge/media/__init__.py @@ -1,27 +1,78 @@ +"""NoteForge 唯一公共媒体 API。""" + +from noteforge.media.assets import AssetReference, MediaAsset from noteforge.media.config import ( ExtractorConfig, PlatformConfig, load_extractor_config, ) +from noteforge.media.cookies import CookieLease, CookieService, CredentialInfo from noteforge.media.models import ( + AudioFormat, + AudioRequest, + AuthRequest, + Browser, + CookiePersistence, + MediaFormats, + MediaType, + Metadata, + Platform, + Playlist, + PlaylistEntry, Subtitle, + SubtitleRequest, SubtitleSegment, + VideoFormat, VideoMetadata, VideoPlatform, + VideoRequest, VideoResource, ) +from noteforge.media.protocols import AudioTranscriber +from noteforge.media.service import MediaService, collect_video, discover_video +from noteforge.media.source import ( + InspectedSource, + InspectionPlatform, + InspectionResult, + inspect_source, +) from noteforge.media.subtitle import SubtitleParser -from noteforge.media.ytdlp import YTDLPClient __all__ = [ + "AssetReference", + "AudioTranscriber", + "AudioFormat", + "AudioRequest", + "AuthRequest", + "Browser", + "CookieLease", + "CookiePersistence", + "CookieService", + "CredentialInfo", "ExtractorConfig", + "MediaAsset", + "MediaFormats", + "MediaService", + "MediaType", + "Metadata", + "Platform", "PlatformConfig", + "Playlist", + "PlaylistEntry", "Subtitle", "SubtitleParser", + "SubtitleRequest", "SubtitleSegment", + "VideoFormat", "VideoMetadata", "VideoPlatform", + "VideoRequest", "VideoResource", - "YTDLPClient", + "InspectedSource", + "InspectionPlatform", + "InspectionResult", + "collect_video", + "discover_video", + "inspect_source", "load_extractor_config", ] diff --git a/src/noteforge/media/assets.py b/src/noteforge/media/assets.py new file mode 100644 index 0000000..3571182 --- /dev/null +++ b/src/noteforge/media/assets.py @@ -0,0 +1,84 @@ +"""有界生命周期的媒体资产。""" + +from __future__ import annotations + +import shutil +import threading +import weakref +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import BinaryIO + +from noteforge.media.models import MediaType, VideoMetadata + + +class AssetExpiredError(RuntimeError): + """访问已关闭或超过 TTL 的媒体资产。""" + + +@dataclass(frozen=True, slots=True) +class AssetReference: + """可安全传递的资产描述,不暴露 Worker 内部参数。""" + + id: str # 单次下载产生的随机资产 ID。 + media_type: MediaType + expires_at: datetime # UTC 绝对过期时间。 + metadata: VideoMetadata + + +class MediaAsset: + """调用方持有的临时资产;close 或退出上下文后立即回收。""" + + def __init__( + self, + reference: AssetReference, + path: Path, + lease_root: Path, + ) -> None: + self.reference = reference + self._path = path # 仅在租约有效期内可访问。 + self._lease_root = lease_root # close 时整体删除,避免遗漏旁路文件。 + self._closed = False + self._lock = threading.Lock() + self._finalizer = weakref.finalize(self, shutil.rmtree, lease_root, True) + + @property + def path(self) -> Path: + """返回有效临时路径;过期访问会先回收再报错。""" + + if self._closed or datetime.now(UTC) >= self.reference.expires_at: + self.close() + raise AssetExpiredError(f"媒体资产 {self.reference.id} 已失效。") + return self._path + + def open(self, mode: str = "rb") -> BinaryIO: + """以二进制流读取临时媒体。""" + + if "b" not in mode: + raise ValueError("媒体资产只能以二进制模式打开。") + return self.path.open(mode) + + def export_to(self, destination: Path) -> Path: + """显式持久化;这是唯一允许长期保存媒体的操作。""" + + destination.parent.mkdir(parents=True, exist_ok=True) + return Path(shutil.copy2(self.path, destination)) + + def close(self) -> None: + """幂等回收整个资产租约目录。""" + + with self._lock: + if self._closed: + return + self._closed = True + self._finalizer() + + def __enter__(self) -> MediaAsset: + return self + + def __exit__(self, *_: object) -> None: + self.close() + + +DEFAULT_ASSET_TTL = timedelta(hours=1) diff --git a/src/noteforge/media/config.py b/src/noteforge/media/config.py index 355e47d..2303f53 100644 --- a/src/noteforge/media/config.py +++ b/src/noteforge/media/config.py @@ -8,49 +8,64 @@ @dataclass(frozen=True, slots=True) class PlatformConfig: - """非交互式认证配置:使用 Cookie 文件或已登录的浏览器配置。""" + """平台网络配置;身份认证由 CookieService 独立管理。""" - cookie_file: Path | None = None - cookies_from_browser: str | None = None - proxy: str | None = None + proxy: str | None = None # 仅作用于该平台;不包含认证信息。 @dataclass(frozen=True, slots=True) class ExtractorConfig: - cache_path: Path = Path(".cache/noteforge/media") - download_path: Path = Path(".cache/noteforge/downloads") + """媒体服务运行配置。""" + + cache_path: Path = Path(".cache/noteforge/media") # 非敏感标准化数据。 platforms: Mapping[str, PlatformConfig] = field(default_factory=dict) + runtime_path: Path = Path(".cache/noteforge/runtime") # 临时媒体租约根目录。 + credential_vault_path: Path = Path(".noteforge/credentials") # 加密凭据库。 + worker_count: int = 2 # 隔离 yt-dlp 的最大进程数。 def for_platform(self, platform: str) -> PlatformConfig: + """返回平台配置;未配置时使用无代理默认值。""" + return self.platforms.get(platform, PlatformConfig()) @classmethod def from_mapping(cls, value: Mapping[str, Any]) -> "ExtractorConfig": + """从已解析的配置映射构造并校正字段。""" + root = value.get("extractor", value) if not isinstance(root, Mapping): return cls() platforms: dict[str, PlatformConfig] = {} - reserved = {"cache_path", "download_path", "proxy"} + reserved = { + "cache_path", + "runtime_path", + "credential_vault_path", + "worker_count", + "proxy", + } for name, item in root.items(): if name in reserved: continue if not isinstance(item, Mapping): continue - cookie = item.get("cookie_file") or item.get("cookies") - browser = item.get("cookies_from_browser") proxy = item.get("proxy") or root.get("proxy") platforms[name] = PlatformConfig( - cookie_file=Path(cookie) - if isinstance(cookie, str) and cookie - else None, - cookies_from_browser=( - browser if isinstance(browser, str) and browser else None - ), proxy=proxy if isinstance(proxy, str) and proxy else None, ) cache = root.get("cache_path", ".cache/noteforge/media") - download = root.get("download_path", ".cache/noteforge/downloads") - return cls(Path(str(cache)), Path(str(download)), platforms) + runtime = root.get("runtime_path", ".cache/noteforge/runtime") + vault = root.get("credential_vault_path", ".noteforge/credentials") + try: + workers = max(1, int(root.get("worker_count", 2))) + except (TypeError, ValueError): + workers = 2 + return cls( + Path(str(cache)), + platforms, + Path(str(runtime)), + Path(str(vault)), + workers, + ) def load_extractor_config(path: str | Path = "config.yaml") -> ExtractorConfig: diff --git a/src/noteforge/media/cookies/__init__.py b/src/noteforge/media/cookies/__init__.py new file mode 100644 index 0000000..9d3346c --- /dev/null +++ b/src/noteforge/media/cookies/__init__.py @@ -0,0 +1,7 @@ +from noteforge.media.cookies.service import ( + CookieLease, + CookieService, + CredentialInfo, +) + +__all__ = ["CookieLease", "CookieService", "CredentialInfo"] diff --git a/src/noteforge/media/cookies/policy.py b/src/noteforge/media/cookies/policy.py new file mode 100644 index 0000000..ea8505c --- /dev/null +++ b/src/noteforge/media/cookies/policy.py @@ -0,0 +1,41 @@ +"""平台 Cookie 最小权限策略。""" + +from dataclasses import dataclass + +from noteforge.media.models import VideoPlatform + + +@dataclass(frozen=True, slots=True) +class CookiePolicy: + """平台固定域名白名单,调用方不能自行扩大范围。""" + + platform: VideoPlatform + allowed_domains: tuple[str, ...] # 允许根域及其子域。 + + def allows(self, domain: str) -> bool: + """执行标签边界匹配,避免伪造后缀域名通过检查。""" + + value = domain.lstrip(".").casefold() + return any( + value == allowed.lstrip(".").casefold() + or value.endswith("." + allowed.lstrip(".").casefold()) + for allowed in self.allowed_domains + ) + + +POLICIES = { + VideoPlatform.YOUTUBE: CookiePolicy( + VideoPlatform.YOUTUBE, + ("youtube.com", "googlevideo.com", "youtu.be"), + ), + VideoPlatform.BILIBILI: CookiePolicy( + VideoPlatform.BILIBILI, + ("bilibili.com",), + ), +} + + +def policy_for(platform: VideoPlatform | str) -> CookiePolicy: + """返回内置平台策略;未知平台直接拒绝。""" + + return POLICIES[VideoPlatform(platform)] diff --git a/src/noteforge/media/cookies/service.py b/src/noteforge/media/cookies/service.py new file mode 100644 index 0000000..7cf79fe --- /dev/null +++ b/src/noteforge/media/cookies/service.py @@ -0,0 +1,366 @@ +"""Cookie 获取、过滤、租约、保留、更新与删除服务。""" + +from __future__ import annotations + +import http.cookiejar +import json +import os +import secrets +import shutil +import tempfile +import threading +import uuid +import weakref +from dataclasses import asdict, dataclass +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from noteforge.media.cookies.policy import CookiePolicy, policy_for +from noteforge.media.models import AuthRequest, CookiePersistence, VideoPlatform + + +class CookieSecurityError(RuntimeError): + """Cookie 生命周期或加密约束被破坏。""" + + +@dataclass(frozen=True, slots=True) +class CredentialInfo: + """持久凭据的非敏感索引信息,不包含 Cookie 值。""" + + id: str # 随机凭据 ID,也是 Vault 子目录名。 + platform: VideoPlatform + browser: str + profile: str | None + created_at: datetime + refreshed_at: datetime + expires_at: datetime | None # 平台未提供整体过期时间时为空。 + cookie_count: int + domains: tuple[str, ...] # 经过平台白名单过滤后的域名。 + version: int = 1 # Vault 数据格式版本。 + + +class CookieLease: + """仅供媒体后端消费的一次性凭据;关闭后明文文件立即删除。""" + + def __init__( + self, + lease_id: str, + path: Path | None, + root: Path, + *, + credential: CredentialInfo | None = None, + on_close: object | None = None, + ) -> None: + self.id = lease_id + self.credential = credential + self._path = path # 权限为 0600 的短期 Netscape Cookie 文件。 + self._root = root # 权限为 0700 的独占租约目录。 + self._on_close = on_close + self._closed = False + self._finalizer = weakref.finalize(self, shutil.rmtree, root, True) + + def _materialize_for_backend(self) -> Path | None: + """内部后端专用;公共 API 不暴露 Cookie 路径。""" + + if self._closed: + raise CookieSecurityError("Cookie 租约已经释放。") + return self._path + + def close(self) -> None: + """可选回写更新后的加密凭据,再无条件删除明文租约。""" + + if not self._closed: + self._closed = True + try: + if callable(self._on_close): + self._on_close(self._path) + finally: + self._finalizer() + + def __enter__(self) -> CookieLease: + return self + + def __exit__(self, *_: object) -> None: + self.close() + + +class CookieService: + """管理平台凭据;Cookie 明文只存在于权限受限的租约目录。""" + + def __init__( + self, + runtime_root: Path | None = None, + vault_root: Path | None = None, + *, + lease_ttl: timedelta = timedelta(minutes=30), + ) -> None: + self.runtime_root = ( + runtime_root or Path(tempfile.gettempdir()) / "noteforge-credentials" + ) + self.vault_root = vault_root or Path.home() / ".noteforge" / "credentials" + self.lease_ttl = lease_ttl # 仅用于回收异常退出后的残留租约。 + self._lock = threading.RLock() + + def anonymous(self) -> CookieLease: + """创建不含 Cookie 的租约,统一匿名与认证执行路径。""" + + root = self._new_lease_root() + return CookieLease(root.name, None, root) + + def acquire( + self, + platform: VideoPlatform | str, + request: AuthRequest | None, + ) -> CookieLease: + """获取目标平台 Cookie,并按请求决定用后删除或加密保留。""" + + if request is None: + return self.anonymous() + platform_value = VideoPlatform(platform) + if request.credential_id: + jar = self._load_retained(request.credential_id, platform_value) + else: + jar = self._extract_browser_cookie_jar(request) + filtered = self._filter(jar, policy_for(platform_value)) + root = self._new_lease_root() + path = root / "cookies.txt" + filtered.filename = str(path) + filtered.save(ignore_discard=True, ignore_expires=True) + os.chmod(path, 0o600) + credential: CredentialInfo | None = None + retained_request = request + if request.persistence is CookiePersistence.RETAIN: + retained_request = AuthRequest( + request.browser, + request.profile, + request.persistence, + request.credential_id or uuid.uuid4().hex, + ) + credential = self.retain(platform_value, retained_request, filtered) + + def update(updated_path: Path | None) -> None: + if credential is None or updated_path is None or not updated_path.exists(): + return + updated = http.cookiejar.MozillaCookieJar(str(updated_path)) + updated.load(ignore_discard=True, ignore_expires=True) + self.retain( + platform_value, + retained_request, + self._filter(updated, policy_for(platform_value)), + ) + + return CookieLease( + root.name, + path, + root, + credential=credential, + on_close=update if credential else None, + ) + + def retain( + self, + platform: VideoPlatform, + request: AuthRequest, + jar: http.cookiejar.MozillaCookieJar, + ) -> CredentialInfo: + """加密保留目标域 Cookie。需要安装 cryptography。""" + + try: + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + except ImportError as error: + raise CookieSecurityError( + "保留 Cookie 需要 cryptography;安全原因禁止降级为明文存储。" + ) from error + credential_id = request.credential_id or uuid.uuid4().hex + root = self.vault_root / credential_id + root.mkdir(parents=True, exist_ok=True, mode=0o700) + os.chmod(root, 0o700) + plain = self._jar_bytes(jar) + key = self._vault_key() + nonce = secrets.token_bytes(12) + aad = f"noteforge:{credential_id}:{platform.value}:v1".encode() + encrypted = nonce + AESGCM(key).encrypt(nonce, plain, aad) + blob = root / "cookies.enc" + blob.write_bytes(encrypted) + os.chmod(blob, 0o600) + now = datetime.now(UTC) + domains = tuple(sorted({cookie.domain for cookie in jar})) + info = CredentialInfo( + credential_id, + platform, + str(request.browser), + request.profile, + now, + now, + None, + len(list(jar)), + domains, + ) + metadata = asdict(info) + metadata["platform"] = info.platform.value + metadata["created_at"] = info.created_at.isoformat() + metadata["refreshed_at"] = info.refreshed_at.isoformat() + (root / "metadata.json").write_text(json.dumps(metadata), encoding="utf-8") + os.chmod(root / "metadata.json", 0o600) + return info + + def revoke(self, credential_id: str) -> None: + """删除指定加密凭据及其非敏感索引。""" + + if not credential_id or any(c not in "0123456789abcdef" for c in credential_id): + raise ValueError("无效的 credential_id。") + shutil.rmtree(self.vault_root / credential_id, ignore_errors=True) + + def list_credentials(self) -> tuple[CredentialInfo, ...]: + """列出可用凭据元数据,跳过损坏条目。""" + + result: list[CredentialInfo] = [] + if not self.vault_root.exists(): + return () + for path in self.vault_root.glob("*/metadata.json"): + try: + value = json.loads(path.read_text(encoding="utf-8")) + result.append( + CredentialInfo( + value["id"], + VideoPlatform(value["platform"]), + value["browser"], + value.get("profile"), + datetime.fromisoformat(value["created_at"]), + datetime.fromisoformat(value["refreshed_at"]), + datetime.fromisoformat(value["expires_at"]) + if value.get("expires_at") + else None, + int(value["cookie_count"]), + tuple(value["domains"]), + int(value.get("version", 1)), + ) + ) + except (KeyError, OSError, TypeError, ValueError, json.JSONDecodeError): + continue + return tuple(sorted(result, key=lambda item: item.created_at)) + + def refresh( + self, + credential_id: str, + platform: VideoPlatform | str, + request: AuthRequest, + ) -> CredentialInfo: + """从浏览器重新获取并原子替换一个保留凭据。""" + + jar = self._filter( + self._extract_browser_cookie_jar(request), + policy_for(platform), + ) + retained_request = AuthRequest( + request.browser, + request.profile, + CookiePersistence.RETAIN, + credential_id, + ) + return self.retain(VideoPlatform(platform), retained_request, jar) + + def cleanup_expired(self) -> int: + """删除超过 TTL 的崩溃残留明文租约。""" + + removed = 0 + cutoff = datetime.now(UTC) - self.lease_ttl + if not self.runtime_root.exists(): + return removed + for path in self.runtime_root.iterdir(): + try: + modified = datetime.fromtimestamp(path.stat().st_mtime, UTC) + except OSError: + continue + if modified < cutoff: + shutil.rmtree(path, ignore_errors=True) + removed += 1 + return removed + + def _new_lease_root(self) -> Path: + """创建只有当前用户可访问的随机租约目录。""" + + self.runtime_root.mkdir(parents=True, exist_ok=True, mode=0o700) + os.chmod(self.runtime_root, 0o700) + return Path(tempfile.mkdtemp(prefix="lease-", dir=self.runtime_root)) + + @staticmethod + def _extract_browser_cookie_jar(request: AuthRequest) -> http.cookiejar.CookieJar: + """在隔离凭据服务中读取浏览器,再在暴露前执行域过滤。 + + yt-dlp 当前浏览器适配层会解密 Cookie;其返回值绝不离开本方法。 + 对必须保证读取阶段也按域隔离的部署,应替换为浏览器扩展 Provider。 + """ + + from yt_dlp.cookies import extract_cookies_from_browser + + profile = request.profile + return extract_cookies_from_browser(str(request.browser), profile=profile) + + @staticmethod + def _filter( + source: http.cookiejar.CookieJar, policy: CookiePolicy + ) -> http.cookiejar.MozillaCookieJar: + """仅复制白名单域 Cookie,源 CookieJar 不会向后端暴露。""" + + target = http.cookiejar.MozillaCookieJar() + for cookie in source: + if policy.allows(cookie.domain): + target.set_cookie(cookie) + return target + + @staticmethod + def _jar_bytes(jar: http.cookiejar.MozillaCookieJar) -> bytes: + with tempfile.NamedTemporaryFile() as output: + jar.filename = output.name + jar.save(ignore_discard=True, ignore_expires=True) + return Path(output.name).read_bytes() + + def _vault_key(self) -> bytes: + """从显式环境变量或系统 Keyring 获取 256 位主密钥。""" + + supplied = os.environ.get("NOTEFORGE_COOKIE_VAULT_KEY") + if supplied: + try: + return bytes.fromhex(supplied) + except ValueError as error: + raise CookieSecurityError( + "NOTEFORGE_COOKIE_VAULT_KEY 必须是 64 位十六进制密钥。" + ) from error + try: + import keyring + except ImportError as error: + raise CookieSecurityError( + "保留 Cookie 需要系统 keyring;禁止把加密密钥与 Cookie 放在同一目录。" + ) from error + service, account = "noteforge-cookie-vault", "local-master-key" + stored = keyring.get_password(service, account) + if stored is None: + stored = secrets.token_hex(32) + keyring.set_password(service, account, stored) + return bytes.fromhex(stored) + + def _load_retained( + self, credential_id: str, platform: VideoPlatform + ) -> http.cookiejar.MozillaCookieJar: + """解密持久凭据到短期文件,加载后立即删除该文件。""" + + try: + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + except ImportError as error: + raise CookieSecurityError("读取保留 Cookie 需要 cryptography。") from error + root = self.vault_root / credential_id + payload = (root / "cookies.enc").read_bytes() + nonce, ciphertext = payload[:12], payload[12:] + aad = f"noteforge:{credential_id}:{platform.value}:v1".encode() + plain = AESGCM(self._vault_key()).decrypt(nonce, ciphertext, aad) + lease = self._new_lease_root() + path = lease / "restore.txt" + try: + path.write_bytes(plain) + os.chmod(path, 0o600) + jar = http.cookiejar.MozillaCookieJar(str(path)) + jar.load(ignore_discard=True, ignore_expires=True) + return jar + finally: + shutil.rmtree(lease, ignore_errors=True) diff --git a/src/noteforge/media/models.py b/src/noteforge/media/models.py index 7d4da99..a8a45e1 100644 --- a/src/noteforge/media/models.py +++ b/src/noteforge/media/models.py @@ -1,4 +1,7 @@ -"""媒体提取领域模型,不向上层泄露具体后端的数据结构。""" +"""媒体领域模型。 + +本模块刻意不包含 yt-dlp 的格式表达式、CLI 参数或 Cookie 内容。 +""" from dataclasses import dataclass from enum import StrEnum @@ -6,43 +9,177 @@ class VideoPlatform(StrEnum): + """当前内置的平台标识。""" + BILIBILI = "bilibili" YOUTUBE = "youtube" +Platform = VideoPlatform + + +class MediaType(StrEnum): + """临时资产的内容类型。""" + + VIDEO = "video" + AUDIO = "audio" + SUBTITLE = "subtitle" + + +class CookiePersistence(StrEnum): + """Cookie 在任务结束后的处理策略。""" + + EPHEMERAL = "ephemeral" + RETAIN = "retain" + + +class Browser(StrEnum): + """Cookie Provider 支持的浏览器名称。""" + + CHROME = "chrome" + CHROMIUM = "chromium" + EDGE = "edge" + FIREFOX = "firefox" + BRAVE = "brave" + VIVALDI = "vivaldi" + OPERA = "opera" + SAFARI = "safari" + + +@dataclass(frozen=True, slots=True) +class AuthRequest: + """请求使用浏览器身份;不包含任何 Cookie 明文。""" + + browser: Browser | str # Cookie 来源浏览器。 + profile: str | None = None # 可选的浏览器配置目录。 + persistence: CookiePersistence = CookiePersistence.EPHEMERAL # 默认用后即删。 + credential_id: str | None = None # 已保留凭据 ID;设置后不再读取浏览器。 + + @dataclass(frozen=True, slots=True) class VideoMetadata: - id: str + """跨平台统一的视频基础信息。""" + + id: str # 平台内稳定 ID。 title: str uploader: str | None - duration: int | None - thumbnail: str | None + duration: int | None # 秒。 + thumbnail: str | None # 缩略图 URL,不下载图片。 platform: str - webpage_url: str + webpage_url: str # 规范化后的详情页 URL。 description: str | None = None +Metadata = VideoMetadata + + +@dataclass(frozen=True, slots=True) +class VideoFormat: + """标准化的视频格式描述。""" + + id: str # 后端格式标识,只能作为 VideoRequest.format_id 原样回传。 + container: str | None + width: int | None = None + height: int | None = None + fps: float | None = None + video_codec: str | None = None + audio_codec: str | None = None + bitrate: float | None = None # kbps。 + file_size: int | None = None # 字节;可能是估算值。 + + +@dataclass(frozen=True, slots=True) +class AudioFormat: + """标准化的音频格式描述。""" + + id: str # 后端格式标识,只能作为 AudioRequest.format_id 原样回传。 + container: str | None + codec: str | None = None + bitrate: float | None = None # kbps。 + sample_rate: int | None = None # Hz。 + file_size: int | None = None # 字节;可能是估算值。 + + +@dataclass(frozen=True, slots=True) +class MediaFormats: + """一次格式发现返回的音视频格式集合。""" + + videos: tuple[VideoFormat, ...] = () + audios: tuple[AudioFormat, ...] = () + + @dataclass(frozen=True, slots=True) class Subtitle: - language: str - format: str - path: Path | None = None - content: str | None = None + """字幕轨道;发现阶段通常只有描述,下载后才有正文或路径。""" + + language: str # 平台返回的语言标识。 + format: str # vtt、srt、ass 或 json3。 + path: Path | None = None # 临时字幕路径,不应长期持有。 + content: str | None = None # 内联字幕正文。 is_automatic: bool = False +@dataclass(frozen=True, slots=True) +class PlaylistEntry: + """播放列表中的轻量条目,不递归提取完整元数据。""" + + id: str + title: str | None + url: str + index: int + + +@dataclass(frozen=True, slots=True) +class Playlist: + """标准化播放列表。""" + + id: str + title: str + entries: tuple[PlaylistEntry, ...] + + +@dataclass(frozen=True, slots=True) +class VideoRequest: + """视频下载约束;调用方无需了解 yt-dlp 格式表达式。""" + + format_id: str | None = None # 来自 list_formats()。 + container: str | None = None + max_height: int | None = None + + +@dataclass(frozen=True, slots=True) +class AudioRequest: + """音频下载与转码约束。""" + + format_id: str | None = None # 来自 list_formats()。 + codec: str = "mp3" # 输出编码格式。 + bitrate: int | None = None # 目标 kbps;后端不支持时可忽略。 + + +@dataclass(frozen=True, slots=True) +class SubtitleRequest: + """指定要下载的字幕语言和输出格式。""" + + language: str + format: str = "vtt" + + @dataclass(frozen=True, slots=True) class SubtitleSegment: - start: float - end: float + """标准化字幕时间片。""" + + start: float # 起始秒数。 + end: float # 结束秒数。 text: str @dataclass(frozen=True, slots=True) class VideoResource: + """面向笔记流水线的聚合结果,不包含 yt-dlp 原始数据。""" + metadata: VideoMetadata subtitles: tuple[Subtitle, ...] = () transcript: tuple[SubtitleSegment, ...] = () audio_path: Path | None = None video_path: Path | None = None - transcript_source: str | None = None + transcript_source: str | None = None # cache/manual/automatic/whisper。 diff --git a/src/noteforge/media/platforms/__init__.py b/src/noteforge/media/platforms/__init__.py new file mode 100644 index 0000000..b01e04e --- /dev/null +++ b/src/noteforge/media/platforms/__init__.py @@ -0,0 +1,5 @@ +from noteforge.media.platforms.base import PlatformAdapter +from noteforge.media.platforms.bilibili import BilibiliAdapter +from noteforge.media.platforms.youtube import YouTubeAdapter + +__all__ = ["BilibiliAdapter", "PlatformAdapter", "YouTubeAdapter"] diff --git a/src/noteforge/media/platforms/base.py b/src/noteforge/media/platforms/base.py new file mode 100644 index 0000000..504eb8b --- /dev/null +++ b/src/noteforge/media/platforms/base.py @@ -0,0 +1,182 @@ +"""平台差异适配器。""" + +from abc import ABC +from collections.abc import Mapping +from typing import Any + +from noteforge.exceptions import RemoteCollectionError, UnsupportedSourceError +from noteforge.media.models import ( + AudioFormat, + MediaFormats, + Playlist, + PlaylistEntry, + Subtitle, + VideoFormat, + VideoMetadata, + VideoPlatform, +) +from noteforge.media.source import InspectionPlatform, inspect_source + + +class PlatformAdapter(ABC): + """把平台/yt-dlp 原始结构转换为稳定领域对象。""" + + platform: VideoPlatform # 子类必须声明唯一平台。 + + def supports(self, source: str) -> bool: + """通过本地 URL 检查判断支持性,不发起网络请求。""" + + return inspect_source(source).platform.value == self.platform.value + + def normalize(self, source: str) -> str: + """移除无关查询参数并生成稳定平台 URL。""" + + result = inspect_source(source) + expected = InspectionPlatform(self.platform.value) + if result.platform is not expected or result.normalized_source is None: + raise UnsupportedSourceError( + f"不支持的 {self.platform.value} URL:{source}" + ) + return result.normalized_source + + def backend_options(self) -> Mapping[str, Any]: + """返回平台必需的内部后端选项,不向调用方开放。""" + + return {} + + def metadata(self, info: Mapping[str, Any], source: str) -> VideoMetadata: + """校验并映射平台元数据。""" + + video_id, title = info.get("id"), info.get("title") + if not isinstance(video_id, str) or not isinstance(title, str): + raise RemoteCollectionError("视频元数据缺少 id 或 title。") + duration = info.get("duration") + return VideoMetadata( + video_id, + title, + info.get("uploader") if isinstance(info.get("uploader"), str) else None, + int(duration) if isinstance(duration, (int, float)) else None, + info.get("thumbnail") if isinstance(info.get("thumbnail"), str) else None, + self.platform.value, + info.get("webpage_url") + if isinstance(info.get("webpage_url"), str) + else source, + info.get("description") + if isinstance(info.get("description"), str) + else None, + ) + + def subtitles(self, info: Mapping[str, Any]) -> tuple[Subtitle, ...]: + """合并人工与自动字幕描述。""" + + result: list[Subtitle] = [] + for key, automatic in (("subtitles", False), ("automatic_captions", True)): + tracks = info.get(key) + if not isinstance(tracks, Mapping): + continue + for language, formats in tracks.items(): + if not isinstance(language, str) or not isinstance(formats, list): + continue + for item in formats: + if isinstance(item, Mapping) and isinstance(item.get("ext"), str): + result.append( + Subtitle( + language, + item["ext"].lower(), + content=item.get("data") + if isinstance(item.get("data"), str) + else None, + is_automatic=automatic + or language.casefold().startswith("ai-"), + ) + ) + return tuple(result) + + def formats(self, info: Mapping[str, Any]) -> MediaFormats: + """把混合格式列表拆成标准化视频与音频集合。""" + + videos: list[VideoFormat] = [] + audios: list[AudioFormat] = [] + raw = info.get("formats") + if not isinstance(raw, list): + return MediaFormats() + for item in raw: + if not isinstance(item, Mapping) or not isinstance( + item.get("format_id"), str + ): + continue + size = item.get("filesize") or item.get("filesize_approx") + common_size = int(size) if isinstance(size, (int, float)) else None + if item.get("vcodec") not in {None, "none"}: + videos.append( + VideoFormat( + item["format_id"], + item.get("ext") if isinstance(item.get("ext"), str) else None, + int(item["width"]) + if isinstance(item.get("width"), (int, float)) + else None, + int(item["height"]) + if isinstance(item.get("height"), (int, float)) + else None, + float(item["fps"]) + if isinstance(item.get("fps"), (int, float)) + else None, + item.get("vcodec") + if isinstance(item.get("vcodec"), str) + else None, + item.get("acodec") + if isinstance(item.get("acodec"), str) + else None, + float(item["tbr"]) + if isinstance(item.get("tbr"), (int, float)) + else None, + common_size, + ) + ) + if item.get("acodec") not in {None, "none"}: + audios.append( + AudioFormat( + item["format_id"], + item.get("ext") if isinstance(item.get("ext"), str) else None, + item.get("acodec") + if isinstance(item.get("acodec"), str) + else None, + float(item["abr"]) + if isinstance(item.get("abr"), (int, float)) + else None, + int(item["asr"]) + if isinstance(item.get("asr"), (int, float)) + else None, + common_size, + ) + ) + return MediaFormats(tuple(videos), tuple(audios)) + + def playlist(self, info: Mapping[str, Any], source: str) -> Playlist: + """映射轻量播放列表;忽略缺少 ID 或 URL 的无效条目。""" + + entries = info.get("entries") + if not isinstance(entries, list): + raise RemoteCollectionError("该来源不是播放列表。") + mapped: list[PlaylistEntry] = [] + for index, item in enumerate(entries, 1): + if not isinstance(item, Mapping): + continue + entry_id = item.get("id") + url = item.get("webpage_url") or item.get("url") + if isinstance(entry_id, str) and isinstance(url, str): + mapped.append( + PlaylistEntry( + entry_id, + item.get("title") + if isinstance(item.get("title"), str) + else None, + url, + index, + ) + ) + return Playlist( + str(info.get("id", source)), + str(info.get("title", "Playlist")), + tuple(mapped), + ) diff --git a/src/noteforge/media/platforms/bilibili.py b/src/noteforge/media/platforms/bilibili.py new file mode 100644 index 0000000..47e617a --- /dev/null +++ b/src/noteforge/media/platforms/bilibili.py @@ -0,0 +1,21 @@ +from collections.abc import Mapping +from typing import Any + +from noteforge.media.models import VideoPlatform +from noteforge.media.platforms.base import PlatformAdapter + + +class BilibiliAdapter(PlatformAdapter): + """补充 Bilibili 请求所需的来源页和语言请求头。""" + + platform = VideoPlatform.BILIBILI + + def backend_options(self) -> Mapping[str, Any]: + """Referer 用于满足 Bilibili 的媒体请求校验。""" + + return { + "http_headers": { + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Referer": "https://www.bilibili.com/", + } + } diff --git a/src/noteforge/media/platforms/youtube.py b/src/noteforge/media/platforms/youtube.py new file mode 100644 index 0000000..52ffa7c --- /dev/null +++ b/src/noteforge/media/platforms/youtube.py @@ -0,0 +1,8 @@ +from noteforge.media.models import VideoPlatform +from noteforge.media.platforms.base import PlatformAdapter + + +class YouTubeAdapter(PlatformAdapter): + """YouTube 适配器;当前无需额外后端请求参数。""" + + platform = VideoPlatform.YOUTUBE diff --git a/src/noteforge/media/protocols.py b/src/noteforge/media/protocols.py new file mode 100644 index 0000000..454024c --- /dev/null +++ b/src/noteforge/media/protocols.py @@ -0,0 +1,23 @@ +"""媒体服务内部可替换组件的接口。""" + +from collections.abc import Mapping +from pathlib import Path +from typing import Any, Protocol + +from noteforge.media.models import SubtitleSegment + + +class AudioTranscriber(Protocol): + """Whisper 或其他语音转文字后端接口。""" + + def transcribe( + self, audio_path: Path, *, language: str | None = None + ) -> tuple[SubtitleSegment, ...]: ... + + +class MediaWorker(Protocol): + """隔离媒体后端的执行接口。""" + + def execute(self, payload: dict[str, Any]) -> Mapping[str, Any]: ... + + def close(self) -> None: ... diff --git a/src/noteforge/media/cache.py b/src/noteforge/media/repository.py similarity index 81% rename from src/noteforge/media/cache.py rename to src/noteforge/media/repository.py index 1093e03..3a2d955 100644 --- a/src/noteforge/media/cache.py +++ b/src/noteforge/media/repository.py @@ -1,4 +1,4 @@ -"""用于存储标准化媒体数据的文件系统缓存。""" +"""标准化媒体元数据与转录文本的持久化仓库。""" import json import tempfile @@ -10,11 +10,15 @@ from noteforge.media.models import SubtitleSegment, VideoMetadata, VideoPlatform -class MediaCache: +class MediaRepository: + """只保存非敏感、小体积的标准化数据,不保存媒体或 Cookie。""" + def __init__(self, root: Path) -> None: self.root = root def video_dir(self, platform: str, video_id: str) -> Path: + """生成经过目录穿越防护的平台资源目录。""" + safe_id = "".join(c if c.isalnum() or c in "._-" else "_" for c in video_id) platform_name = ( platform.value if isinstance(platform, VideoPlatform) else platform @@ -22,6 +26,8 @@ def video_dir(self, platform: str, video_id: str) -> Path: return self.root / platform_name / safe_id def load_metadata(self, platform: str, video_id: str) -> VideoMetadata | None: + """读取元数据;文件缺失或结构损坏时视为未命中。""" + value = self._read(self.video_dir(platform, video_id) / "metadata.json") if not isinstance(value, dict): return None @@ -31,6 +37,8 @@ def load_metadata(self, platform: str, video_id: str) -> VideoMetadata | None: return None def save_metadata(self, metadata: VideoMetadata) -> Path: + """原子写入标准化元数据。""" + return self._write( self.video_dir(metadata.platform, metadata.id) / "metadata.json", asdict(metadata), @@ -39,6 +47,8 @@ def save_metadata(self, metadata: VideoMetadata) -> Path: def load_transcript( self, platform: str, video_id: str ) -> tuple[SubtitleSegment, ...] | None: + """读取标准化字幕片段;无效缓存不会传播到上层。""" + value = self._read(self.video_dir(platform, video_id) / "subtitle.json") if not isinstance(value, list): return None @@ -50,6 +60,8 @@ def load_transcript( def save_transcript( self, metadata: VideoMetadata, segments: tuple[SubtitleSegment, ...] ) -> Path: + """原子写入标准化字幕片段。""" + return self._write( self.video_dir(metadata.platform, metadata.id) / "subtitle.json", [asdict(item) for item in segments], @@ -64,6 +76,8 @@ def _read(path: Path) -> Any: @staticmethod def _write(path: Path, value: Any) -> Path: + """先写同目录临时文件,再替换目标,避免半写入文件。""" + path.parent.mkdir(parents=True, exist_ok=True) def default(item: Any) -> Any: diff --git a/src/noteforge/media/service.py b/src/noteforge/media/service.py new file mode 100644 index 0000000..9af73a2 --- /dev/null +++ b/src/noteforge/media/service.py @@ -0,0 +1,453 @@ +"""统一媒体服务,是应用层唯一允许调用的媒体入口。""" + +from __future__ import annotations + +import shutil +import tempfile +import uuid +from collections.abc import Mapping +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +from noteforge.exceptions import RemoteCollectionError, UnsupportedSourceError +from noteforge.media.assets import AssetReference, MediaAsset +from noteforge.media.config import ExtractorConfig, load_extractor_config +from noteforge.media.cookies import CookieService +from noteforge.media.models import ( + AudioRequest, + AuthRequest, + Browser, + MediaFormats, + MediaType, + Playlist, + Subtitle, + SubtitleRequest, + VideoMetadata, + VideoRequest, + VideoResource, +) +from noteforge.media.platforms import BilibiliAdapter, PlatformAdapter, YouTubeAdapter +from noteforge.media.protocols import AudioTranscriber, MediaWorker +from noteforge.media.repository import MediaRepository +from noteforge.media.subtitle import SubtitleParser +from noteforge.media.worker import ProcessMediaWorker + + +class MediaService: + """统一编排平台识别、认证租约、Worker 和资产生命周期。""" + + def __init__( + self, + config: ExtractorConfig | None = None, + *, + cookie_service: CookieService | None = None, + worker: MediaWorker | None = None, + transcriber: AudioTranscriber | None = None, + asset_ttl: timedelta = timedelta(hours=1), + ) -> None: + self.config = config or ExtractorConfig() + # Repository 只持久化元数据/文本;媒体二进制始终进入临时资产目录。 + self.repository = MediaRepository(self.config.cache_path) + self.cookies = cookie_service or CookieService( + vault_root=self.config.credential_vault_path + ) + self.worker = worker or ProcessMediaWorker(self.config.worker_count) + self.transcriber = transcriber + self.asset_ttl = asset_ttl + self._legacy_assets: list[MediaAsset] = [] + self._adapters: tuple[PlatformAdapter, ...] = ( + BilibiliAdapter(), + YouTubeAdapter(), + ) + + def supports(self, source: str) -> bool: + """判断任一已注册平台是否支持该来源。""" + + return any(adapter.supports(source) for adapter in self._adapters) + + def extract_metadata( + self, source: str, *, auth: AuthRequest | None = None + ) -> VideoMetadata: + """提取并标准化元数据,不下载媒体正文。""" + + adapter, normalized = self._resolve(source) + info = self._extract(adapter, normalized, auth=auth) + metadata = adapter.metadata(info, normalized) + self.repository.save_metadata(metadata) + return metadata + + def list_formats( + self, source: str, *, auth: AuthRequest | None = None + ) -> MediaFormats: + """列出标准化格式,不向调用方暴露 yt-dlp 参数结构。""" + + adapter, normalized = self._resolve(source) + return adapter.formats(self._extract(adapter, normalized, auth=auth)) + + def list_subtitles( + self, source: str, *, auth: AuthRequest | None = None + ) -> tuple[Subtitle, ...]: + """列出人工与自动字幕轨道。""" + + adapter, normalized = self._resolve(source) + return adapter.subtitles(self._extract(adapter, normalized, auth=auth)) + + def extract_playlist( + self, source: str, *, auth: AuthRequest | None = None + ) -> Playlist: + """以轻量模式提取播放列表,避免递归下载条目。""" + + adapter, normalized = self._resolve(source) + info = self._extract( + adapter, + normalized, + auth=auth, + options={"noplaylist": False, "extract_flat": True}, + ) + return adapter.playlist(info, normalized) + + def download_video( + self, + source: str, + request: VideoRequest | None = None, + *, + auth: AuthRequest | None = None, + ) -> MediaAsset: + """下载视频并返回必须关闭的临时资产。""" + + return self._download_media(source, request or VideoRequest(), False, auth) + + def download_audio( + self, + source: str, + request: AudioRequest | None = None, + *, + auth: AuthRequest | None = None, + ) -> MediaAsset: + """下载并按请求转码音频,结果受资产租约控制。""" + + return self._download_media(source, request or AudioRequest(), True, auth) + + def download_subtitle( + self, + source: str, + request: SubtitleRequest, + *, + auth: AuthRequest | None = None, + ) -> MediaAsset: + """下载指定字幕轨道,异常时立即回收临时目录。""" + + adapter, normalized = self._resolve(source) + metadata = self.extract_metadata(normalized, auth=auth) + root = self._asset_root() + try: + with self.cookies.acquire(adapter.platform, auth) as credential: + info = self.worker.execute( + { + "operation": "download_subtitle", + "source": normalized, + "target_dir": str(root), + "language": request.language, + "subtitle_format": request.format, + "cookie_file": self._credential_path(credential), + "platform_options": dict(adapter.backend_options()), + } + ) + requested = info.get("requested_subtitles") + item = ( + requested.get(request.language) + if isinstance(requested, Mapping) + else None + ) + path_value = item.get("filepath") if isinstance(item, Mapping) else None + if not isinstance(path_value, str): + raise RemoteCollectionError("媒体 Worker 未返回字幕文件。") + return self._asset(MediaType.SUBTITLE, metadata, Path(path_value), root) + except Exception: + shutil.rmtree(root, ignore_errors=True) + raise + + def discover( + self, source: str, *, auth: AuthRequest | None = None + ) -> VideoResource: + """聚合元数据和字幕轨道,不返回后端原始对象。""" + + adapter, normalized = self._resolve(source) + info = self._extract(adapter, normalized, auth=auth) + metadata = adapter.metadata(info, normalized) + self.repository.save_metadata(metadata) + return VideoResource(metadata=metadata, subtitles=adapter.subtitles(info)) + + def extract( + self, + source: str, + *, + subtitle_language: str | None = None, + download_audio: bool = False, + download_video: bool = False, + auth: AuthRequest | None = None, + ) -> VideoResource: + """为笔记流水线生成字幕转录,必要时回退到音频转写。""" + + resource = self.discover(source, auth=auth) + metadata = resource.metadata + transcript = ( + self.repository.load_transcript(metadata.platform, metadata.id) or () + ) + transcript_source = "cache" if transcript else None + subtitles = resource.subtitles + selected = ( + self.select_subtitle(subtitles, subtitle_language) + if not transcript + else None + ) + if selected: + if selected.content is not None: + transcript = SubtitleParser().parse(selected) + else: + with self.download_subtitle( + source, + SubtitleRequest(selected.language, selected.format), + auth=auth, + ) as asset: + selected = Subtitle( + selected.language, + selected.format, + content=asset.path.read_text(encoding="utf-8"), + is_automatic=selected.is_automatic, + ) + transcript = SubtitleParser().parse(selected) + self.repository.save_transcript(metadata, transcript) + transcript_source = ( + "automatic_subtitle" if selected.is_automatic else "manual_subtitle" + ) + audio_asset = self.download_audio(source, auth=auth) if download_audio else None + if not transcript and self.transcriber: + owned = audio_asset or self.download_audio(source, auth=auth) + try: + transcript = self.transcriber.transcribe( + owned.path, language=subtitle_language + ) + self.repository.save_transcript(metadata, transcript) + transcript_source = "whisper" + finally: + if audio_asset is None: + owned.close() + video_asset = self.download_video(source, auth=auth) if download_video else None + self._legacy_assets.extend( + asset for asset in (audio_asset, video_asset) if asset is not None + ) + # 兼容旧对象时由调用方负责本次进程内路径;新代码应直接使用 MediaAsset。 + return VideoResource( + metadata, + subtitles, + transcript, + audio_asset.path if audio_asset else None, + video_asset.path if video_asset else None, + transcript_source, + ) + + @staticmethod + def select_subtitle( + subtitles: tuple[Subtitle, ...], preferred: str | None + ) -> Subtitle | None: + """优先人工、指定语言和更易解析的字幕格式。""" + + order = {"vtt": 0, "srt": 1, "ass": 2, "json3": 3} + candidates = [item for item in subtitles if item.format in order] + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + item.is_automatic, + 0 + if preferred and item.language.casefold() == preferred.casefold() + else 1, + order[item.format], + ), + ) + + def cleanup(self) -> int: + """清理 Cookie 与媒体的崩溃遗留租约,返回删除数量。""" + + removed = self.cookies.cleanup_expired() + cutoff = datetime.now(UTC) - self.asset_ttl + if not self.config.runtime_path.exists(): + return removed + for path in self.config.runtime_path.glob("asset-*"): + try: + modified = datetime.fromtimestamp(path.stat().st_mtime, UTC) + except OSError: + continue + if modified < cutoff: + shutil.rmtree(path, ignore_errors=True) + removed += 1 + return removed + + def close(self) -> None: + """回收兼容资产并关闭 Worker 池;可重复调用。""" + + for asset in self._legacy_assets: + asset.close() + self._legacy_assets.clear() + self.worker.close() + + def __enter__(self) -> MediaService: + return self + + def __exit__(self, *_: object) -> None: + self.close() + + def _resolve(self, source: str) -> tuple[PlatformAdapter, str]: + """选择平台适配器并返回规范化 URL。""" + + for adapter in self._adapters: + if adapter.supports(source): + return adapter, adapter.normalize(source) + raise UnsupportedSourceError(f"不支持的视频 URL:{source}") + + def _extract( + self, + adapter: PlatformAdapter, + source: str, + *, + auth: AuthRequest | None, + options: Mapping[str, Any] | None = None, + ) -> Mapping[str, Any]: + """在 Cookie 租约范围内执行只读发现操作。""" + + with self.cookies.acquire(adapter.platform, auth) as credential: + return self.worker.execute( + { + "operation": "extract", + "source": source, + "options": dict(options or {}), + "cookie_file": self._credential_path(credential), + "platform_options": dict(adapter.backend_options()) + | self._proxy_options(adapter), + } + ) + + def _download_media( + self, + source: str, + request: VideoRequest | AudioRequest, + audio_only: bool, + auth: AuthRequest | None, + ) -> MediaAsset: + """执行媒体下载并把 Worker 文件包装成受控资产。""" + + adapter, normalized = self._resolve(source) + metadata = self.extract_metadata(normalized, auth=auth) + root = self._asset_root() + try: + with self.cookies.acquire(adapter.platform, auth) as credential: + info = self.worker.execute( + { + "operation": "download_media", + "source": normalized, + "target_dir": str(root), + "audio_only": audio_only, + "format_id": request.format_id, + "codec": request.codec + if isinstance(request, AudioRequest) + else "mp3", + "cookie_file": self._credential_path(credential), + "platform_options": dict(adapter.backend_options()) + | self._proxy_options(adapter), + } + ) + path = self._downloaded_path(info, audio_only, request) + return self._asset( + MediaType.AUDIO if audio_only else MediaType.VIDEO, metadata, path, root + ) + except Exception: + shutil.rmtree(root, ignore_errors=True) + raise + + def _proxy_options(self, adapter: PlatformAdapter) -> dict[str, Any]: + proxy = self.config.for_platform(adapter.platform.value).proxy + return {"proxy": proxy} if proxy else {} + + @staticmethod + def _credential_path(credential: Any) -> str | None: + path = credential._materialize_for_backend() + return str(path) if path else None + + def _asset_root(self) -> Path: + self.config.runtime_path.mkdir(parents=True, exist_ok=True) + return Path(tempfile.mkdtemp(prefix="asset-", dir=self.config.runtime_path)) + + def _asset( + self, media_type: MediaType, metadata: VideoMetadata, path: Path, root: Path + ) -> MediaAsset: + reference = AssetReference( + uuid.uuid4().hex, + media_type, + datetime.now(UTC) + self.asset_ttl, + metadata, + ) + return MediaAsset(reference, path, root) + + @staticmethod + def _downloaded_path( + info: Mapping[str, Any], audio_only: bool, request: VideoRequest | AudioRequest + ) -> Path: + downloads = info.get("requested_downloads") + value = ( + downloads[0].get("filepath") + if isinstance(downloads, list) + and downloads + and isinstance(downloads[0], Mapping) + else info.get("_filename") + ) + if not isinstance(value, str) or not value: + raise RemoteCollectionError("媒体 Worker 未返回下载文件路径。") + path = Path(value) + if audio_only and isinstance(request, AudioRequest): + converted = path.with_suffix(f".{request.codec}") + if converted.exists(): + return converted + return path + + +def _auth(browser: str | None) -> AuthRequest | None: + return AuthRequest(Browser(browser)) if browser else None + + +def collect_video( + source: str, + *, + cookies_from_browser: str | None = None, + subtitle_language: str | None = None, + subtitle_output_dir: Path | None = None, + page_number: int | None = None, +) -> VideoResource: + """CLI/流水线使用的媒体便捷入口。""" + + del page_number + configured = load_extractor_config() + config = ExtractorConfig( + cache_path=subtitle_output_dir or configured.cache_path, + platforms=configured.platforms, + runtime_path=configured.runtime_path, + credential_vault_path=configured.credential_vault_path, + worker_count=configured.worker_count, + ) + with MediaService(config) as media: + return media.extract( + source, + subtitle_language=subtitle_language, + auth=_auth(cookies_from_browser), + ) + + +def discover_video( + source: str, *, cookies_from_browser: str | None = None +) -> VideoResource: + """CLI 诊断使用的媒体发现入口。""" + + with MediaService(load_extractor_config()) as media: + return media.discover(source, auth=_auth(cookies_from_browser)) diff --git a/src/noteforge/media/source.py b/src/noteforge/media/source.py new file mode 100644 index 0000000..c65a0eb --- /dev/null +++ b/src/noteforge/media/source.py @@ -0,0 +1,79 @@ +"""媒体 URL 识别与规范化。""" + +import re +from dataclasses import dataclass +from enum import StrEnum +from urllib.parse import parse_qs, urlparse + +_BVID = re.compile(r"BV[0-9A-Za-z]{10}") +_YOUTUBE_ID = re.compile(r"[0-9A-Za-z_-]{11}") + + +class InspectionPlatform(StrEnum): + """URL 本地检查结果;UNKNOWN 不会触发远端解析。""" + + BILIBILI = "bilibili" + YOUTUBE = "youtube" + UNKNOWN = "unknown" + + +@dataclass(frozen=True, slots=True) +class InspectedSource: + """来源检查结果及其规范化地址。""" + + original_source: str + platform: InspectionPlatform + source_id: str | None = None + normalized_source: str | None = None + page_number: int | None = None + requires_remote_resolution: bool = False + + +def inspect_source(source: str) -> InspectedSource: + """纯本地识别 Bilibili/YouTube URL,并保留 B 站分 P。""" + + cleaned = source.strip() + for character in ("?", "=", "&"): + cleaned = cleaned.replace(f"\\{character}", character) + parsed = urlparse(cleaned) + host = (parsed.hostname or "").casefold() + parts = [part for part in parsed.path.split("/") if part] + if host in {"bilibili.com", "www.bilibili.com"}: + if len(parts) >= 2 and parts[0] == "video" and _BVID.fullmatch(parts[1]): + try: + page = max(1, int(parse_qs(parsed.query).get("p", ["1"])[0])) + except ValueError: + page = 1 + normalized = f"https://www.bilibili.com/video/{parts[1]}" + if page != 1: + normalized += f"?p={page}" + return InspectedSource( + source, + InspectionPlatform.BILIBILI, + parts[1], + normalized, + page, + ) + video_id: str | None = None + if host in {"youtu.be", "www.youtu.be"} and parts: + video_id = parts[0] + elif host in { + "youtube.com", + "www.youtube.com", + "m.youtube.com", + "music.youtube.com", + }: + if parsed.path.rstrip("/") == "/watch": + video_id = parse_qs(parsed.query).get("v", [None])[0] + elif len(parts) >= 2 and parts[0] in {"embed", "live", "shorts", "v"}: + video_id = parts[1] + elif host in {"youtube-nocookie.com", "www.youtube-nocookie.com"}: + if len(parts) >= 2 and parts[0] == "embed": + video_id = parts[1] + if video_id and _YOUTUBE_ID.fullmatch(video_id): + normalized = f"https://www.youtube.com/watch?v={video_id}" + return InspectedSource(source, InspectionPlatform.YOUTUBE, video_id, normalized) + return InspectedSource(source, InspectionPlatform.UNKNOWN) + + +InspectionResult = InspectedSource diff --git a/src/noteforge/media/subtitle.py b/src/noteforge/media/subtitle.py index c9a87c9..eaff35a 100644 --- a/src/noteforge/media/subtitle.py +++ b/src/noteforge/media/subtitle.py @@ -12,7 +12,11 @@ class SubtitleParser: + """将平台字幕解析为统一时间片,并清理标签和重复文本。""" + def parse(self, subtitle: Subtitle) -> tuple[SubtitleSegment, ...]: + """按格式解析字幕正文;路径仅允许指向有效临时资产。""" + content = subtitle.content if content is None and subtitle.path is not None: try: diff --git a/src/noteforge/media/transcriber.py b/src/noteforge/media/transcriber.py deleted file mode 100644 index e2d6fee..0000000 --- a/src/noteforge/media/transcriber.py +++ /dev/null @@ -1,12 +0,0 @@ -"""为 Whisper 或其他语音转文字后端提供的扩展接口。""" - -from pathlib import Path -from typing import Protocol - -from noteforge.media.models import SubtitleSegment - - -class AudioTranscriber(Protocol): - def transcribe( - self, audio_path: Path, *, language: str | None = None - ) -> tuple[SubtitleSegment, ...]: ... diff --git a/src/noteforge/media/worker.py b/src/noteforge/media/worker.py new file mode 100644 index 0000000..5843a6d --- /dev/null +++ b/src/noteforge/media/worker.py @@ -0,0 +1,86 @@ +"""隔离执行 yt-dlp 的本地媒体 Worker。""" + +from __future__ import annotations + +from collections.abc import Mapping +from concurrent.futures import ProcessPoolExecutor +from pathlib import Path +from typing import Any + +from noteforge.media.ytdlp import YTDLPClient + + +def _execute(payload: dict[str, Any]) -> Mapping[str, Any]: + """子进程入口;只接收可序列化命令,不接触应用领域对象。""" + + client = YTDLPClient(extra_options=payload.get("platform_options")) + operation = payload["operation"] + cookie = Path(payload["cookie_file"]) if payload.get("cookie_file") else None + if operation == "extract": + return client.extract_info( + payload["source"], + options=payload.get("options"), + cookie_file=cookie, + ) + if operation == "download_media": + return client.download_media( + payload["source"], + target_dir=Path(payload["target_dir"]), + audio_only=payload["audio_only"], + format_id=payload.get("format_id"), + codec=payload.get("codec", "mp3"), + cookie_file=cookie, + ) + if operation == "download_subtitle": + return client.download_subtitle( + payload["source"], + language=payload["language"], + subtitle_format=payload["subtitle_format"], + target_dir=Path(payload["target_dir"]), + cookie_file=cookie, + ) + raise ValueError(f"未知媒体 Worker 操作:{operation}") + + +class ProcessMediaWorker: + """懒启动的 yt-dlp 进程池,隔离下载器故障和资源占用。""" + + def __init__(self, max_workers: int = 2) -> None: + self._max_workers = max_workers + self._executor: ProcessPoolExecutor | None = None + self._closed = False + + def execute(self, payload: dict[str, Any]) -> Mapping[str, Any]: + """同步等待一次子进程任务;异常会原样传播到服务层。""" + + if self._closed: + raise RuntimeError("Media Worker 已关闭。") + if self._executor is None: + self._executor = ProcessPoolExecutor(max_workers=self._max_workers) + return self._executor.submit(_execute, payload).result() + + def close(self) -> None: + """幂等关闭进程池,并取消尚未开始的任务。""" + + if not self._closed: + self._closed = True + if self._executor is not None: + self._executor.shutdown(wait=True, cancel_futures=True) + + def __enter__(self) -> ProcessMediaWorker: + return self + + def __exit__(self, *_: object) -> None: + self.close() + + +class InProcessMediaWorker: + """仅用于测试或受控嵌入环境。生产默认不使用。""" + + def execute(self, payload: dict[str, Any]) -> Mapping[str, Any]: + """在当前进程执行同一命令协议。""" + + return _execute(payload) + + def close(self) -> None: + pass diff --git a/src/noteforge/media/ytdlp/client.py b/src/noteforge/media/ytdlp/client.py index 5d20406..fcf78fe 100644 --- a/src/noteforge/media/ytdlp/client.py +++ b/src/noteforge/media/ytdlp/client.py @@ -9,11 +9,12 @@ from yt_dlp.utils import DownloadError from noteforge.exceptions import CollectionError, RemoteCollectionError -from noteforge.media.config import PlatformConfig from noteforge.media.ytdlp.errors import translate_download_error class _QuietLogger: + """阻止 yt-dlp 把可能含 URL 或认证上下文的信息写入应用日志。""" + def debug(self, _: str) -> None: pass @@ -35,14 +36,14 @@ class YTDLPClient: def __init__( self, - settings: PlatformConfig | None = None, *, extra_options: Mapping[str, Any] | None = None, ) -> None: - self.settings = settings or PlatformConfig() self.extra_options = dict(extra_options or {}) def options(self) -> dict[str, Any]: + """构造安全默认选项;调用方无法直接传入该结构。""" + result: dict[str, Any] = { "quiet": True, "no_warnings": True, @@ -56,20 +57,6 @@ def options(self) -> dict[str, Any]: "writeautomaticsub": True, "impersonate": ImpersonateTarget(client="chrome"), } - cookie_file = ( - self.settings.cookie_file.expanduser() - if self.settings.cookie_file - else None - ) - if cookie_file: - result["cookiefile"] = str(cookie_file) - if not cookie_file.exists() and self.settings.cookies_from_browser: - cookie_file.parent.mkdir(parents=True, exist_ok=True) - result["cookiesfrombrowser"] = (self.settings.cookies_from_browser,) - elif self.settings.cookies_from_browser: - result["cookiesfrombrowser"] = (self.settings.cookies_from_browser,) - if self.settings.proxy: - result["proxy"] = self.settings.proxy result.update(self.extra_options) return result @@ -79,8 +66,13 @@ def extract_info( *, download: bool = False, options: Mapping[str, Any] | None = None, + cookie_file: Path | None = None, ) -> Mapping[str, Any]: + """执行一次 yt-dlp 提取,并统一翻译后端异常。""" + params = self.options() | dict(options or {}) + if cookie_file is not None: + params["cookiefile"] = str(cookie_file) try: with yt_dlp.YoutubeDL(params) as downloader: info = downloader.extract_info(source, download=download) @@ -95,8 +87,16 @@ def extract_info( return info def download_subtitle( - self, source: str, *, language: str, subtitle_format: str, target_dir: Path + self, + source: str, + *, + language: str, + subtitle_format: str, + target_dir: Path, + cookie_file: Path | None = None, ) -> Mapping[str, Any]: + """下载单条指定字幕到 Worker 分配的临时目录。""" + target_dir.mkdir(parents=True, exist_ok=True) return self.extract_info( source, @@ -109,21 +109,34 @@ def download_subtitle( "subtitle": str(target_dir / "subtitle.%(ext)s"), }, }, + cookie_file=cookie_file, ) def download_media( - self, source: str, *, target_dir: Path, audio_only: bool + self, + source: str, + *, + target_dir: Path, + audio_only: bool, + format_id: str | None = None, + codec: str = "mp3", + cookie_file: Path | None = None, ) -> Mapping[str, Any]: + """下载视频或提取音频;格式选择由领域请求映射而来。""" + target_dir.mkdir(parents=True, exist_ok=True) options: dict[str, Any] = { "skip_download": False, # 真正下载媒体时必须存在匹配格式,不能沿用发现阶段的宽松策略。 "ignore_no_formats_error": False, - "format": "bestaudio/best" if audio_only else "bestvideo+bestaudio/best", + "format": format_id + or ("bestaudio/best" if audio_only else "bestvideo+bestaudio/best"), "outtmpl": str(target_dir / "%(id)s.%(ext)s"), } if audio_only: options["postprocessors"] = [ - {"key": "FFmpegExtractAudio", "preferredcodec": "mp3"} + {"key": "FFmpegExtractAudio", "preferredcodec": codec} ] - return self.extract_info(source, download=True, options=options) + return self.extract_info( + source, download=True, options=options, cookie_file=cookie_file + ) diff --git a/src/noteforge/media/ytdlp/errors.py b/src/noteforge/media/ytdlp/errors.py index 0e5bf72..8c80ae1 100644 --- a/src/noteforge/media/ytdlp/errors.py +++ b/src/noteforge/media/ytdlp/errors.py @@ -13,6 +13,8 @@ def translate_download_error(error: DownloadError) -> CollectionError: + """将不稳定的后端错误文本归类为可供应用处理的异常。""" + message = str(error) normalized = message.casefold() if "unsupported url" in normalized or "no suitable extractor" in normalized: diff --git a/src/noteforge/renderer/__init__.py b/src/noteforge/renderer/__init__.py deleted file mode 100644 index f294a22..0000000 --- a/src/noteforge/renderer/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""学习文档的输出格式转换与文件写入工具。""" - -from noteforge.renderer.markdown import MarkdownRenderer -from noteforge.renderer.writer import write_markdown - -__all__ = ["MarkdownRenderer", "write_markdown"] diff --git a/tests/cli/test_app.py b/tests/cli/test_app.py index 5f77353..0bdd2e2 100644 --- a/tests/cli/test_app.py +++ b/tests/cli/test_app.py @@ -6,10 +6,10 @@ from typer.testing import CliRunner from noteforge.cli.app import app -from noteforge.collector import source as inspection from noteforge.config import LLMSettings from noteforge.core import NoteGenerationPipeline from noteforge.exceptions import RemoteCollectionError, RiskControlError +from noteforge.media import source as inspection from noteforge.media.models import Subtitle, SubtitleSegment, VideoResource from noteforge.media.models import VideoMetadata as MediaMetadata diff --git a/tests/collector/test_bilibili.py b/tests/collector/test_bilibili.py index 9f6bee5..d014f67 100644 --- a/tests/collector/test_bilibili.py +++ b/tests/collector/test_bilibili.py @@ -1,15 +1,15 @@ -"""验证 Bilibili 平台采集器的应用策略。""" +"""验证 Bilibili 平台适配策略。""" -from noteforge.collector.platforms.bilibili import BilibiliVideoCollector +from noteforge.media.platforms import BilibiliAdapter -def test_bilibili_collector_recognizes_and_normalizes_url() -> None: - collector = BilibiliVideoCollector() +def test_bilibili_adapter_recognizes_and_normalizes_url() -> None: + adapter = BilibiliAdapter() source = "https://www.bilibili.com/video/BV1CkArz1E4o?p=2" - assert collector.supports(source) - assert collector.normalize(source) == source + assert adapter.supports(source) + assert adapter.normalize(source) == source -def test_bilibili_collector_provides_platform_headers() -> None: - headers = BilibiliVideoCollector().ytdlp_options()["http_headers"] +def test_bilibili_adapter_provides_platform_headers() -> None: + headers = BilibiliAdapter().backend_options()["http_headers"] assert headers["Referer"] == "https://www.bilibili.com/" diff --git a/tests/collector/test_inspection.py b/tests/collector/test_inspection.py index d0ac4f0..4242f8d 100644 --- a/tests/collector/test_inspection.py +++ b/tests/collector/test_inspection.py @@ -1,6 +1,6 @@ import pytest -from noteforge.collector.source import InspectionPlatform, inspect_source +from noteforge.media.source import InspectionPlatform, inspect_source def test_inspect_standard_bilibili_url() -> None: diff --git a/tests/renderer/test_markdown_renderer.py b/tests/document/test_markdown_renderer.py similarity index 96% rename from tests/renderer/test_markdown_renderer.py rename to tests/document/test_markdown_renderer.py index 67ee916..efd4a5d 100644 --- a/tests/renderer/test_markdown_renderer.py +++ b/tests/document/test_markdown_renderer.py @@ -2,12 +2,16 @@ import pytest -from noteforge.document import DocumentSection, LearningDocument +from noteforge.document import ( + DocumentSection, + LearningDocument, + MarkdownRenderer, + write_markdown, +) from noteforge.knowledge.chunker import RawChunk from noteforge.knowledge.extraction import KnowledgePoint, KnowledgePointType from noteforge.knowledge.preprocessor import PreprocessedChunk from noteforge.knowledge.semantic import SemanticChunk, SemanticChunkType -from noteforge.renderer import MarkdownRenderer, write_markdown def make_point( diff --git a/tests/media/test_media.py b/tests/media/test_media.py index da36bec..b7ba01f 100644 --- a/tests/media/test_media.py +++ b/tests/media/test_media.py @@ -1,50 +1,67 @@ +import http.cookiejar +from datetime import UTC, datetime, timedelta from pathlib import Path from unittest.mock import patch -from noteforge.collector.factory import create_video_collector -from noteforge.collector.platforms.bilibili import BilibiliVideoCollector -from noteforge.collector.platforms.youtube import YouTubeCollector -from noteforge.media.cache import MediaCache +from noteforge.media.assets import AssetReference, MediaAsset from noteforge.media.config import ( ExtractorConfig, - PlatformConfig, load_extractor_config, ) +from noteforge.media.cookies.policy import policy_for +from noteforge.media.cookies.service import CookieService from noteforge.media.models import ( + AudioRequest, + MediaType, Subtitle, SubtitleSegment, VideoMetadata, VideoPlatform, ) +from noteforge.media.platforms import BilibiliAdapter, YouTubeAdapter +from noteforge.media.repository import MediaRepository +from noteforge.media.service import MediaService from noteforge.media.subtitle import SubtitleParser from noteforge.media.ytdlp import YTDLPClient -def test_collector_factory_recognizes_bilibili_and_youtube() -> None: - assert isinstance( - create_video_collector("https://www.bilibili.com/video/BV1CkArz1E4o"), - BilibiliVideoCollector, - ) - assert isinstance( - create_video_collector("https://youtu.be/M7lc1UVf-VE"), YouTubeCollector - ) +def test_platform_adapters_recognize_bilibili_and_youtube() -> None: + assert BilibiliAdapter().supports("https://www.bilibili.com/video/BV1CkArz1E4o") + assert YouTubeAdapter().supports("https://youtu.be/M7lc1UVf-VE") -def test_cookie_file_has_priority_over_browser_cookie(tmp_path: Path) -> None: - (tmp_path / "cookies.txt").touch() - options = YTDLPClient( - PlatformConfig(tmp_path / "cookies.txt", "chrome", None) - ).options() - assert options["cookiefile"] == str(tmp_path / "cookies.txt") +def test_ytdlp_options_cannot_read_browser_cookies_directly() -> None: + options = YTDLPClient().options() + assert "cookiefile" not in options assert "cookiesfrombrowser" not in options -def test_missing_cookie_file_bootstraps_from_browser_once(tmp_path: Path) -> None: - cookie_file = tmp_path / "private" / "cookies.txt" - options = YTDLPClient(PlatformConfig(cookie_file, "chrome", None)).options() - assert options["cookiefile"] == str(cookie_file) - assert options["cookiesfrombrowser"] == ("chrome",) - assert cookie_file.parent.is_dir() +def test_cookie_service_filters_non_platform_domains() -> None: + source = http.cookiejar.CookieJar() + for domain in (".youtube.com", ".example.com"): + source.set_cookie( + http.cookiejar.Cookie( + 0, + "session", + "secret", + None, + False, + domain, + True, + True, + "/", + True, + False, + None, + False, + None, + None, + {}, + False, + ) + ) + filtered = CookieService._filter(source, policy_for(VideoPlatform.YOUTUBE)) + assert [cookie.domain for cookie in filtered] == [".youtube.com"] def test_metadata_discovery_allows_missing_media_formats() -> None: @@ -68,13 +85,12 @@ def test_load_yaml_style_extractor_config(tmp_path: Path) -> None: path = tmp_path / "config.yaml" path.write_text( "extractor:\n cache_path: .cache/media\n youtube:\n" - " cookie_file: .secrets/youtube.txt\n" - " cookies_from_browser: chrome\n", + " proxy: http://127.0.0.1:7890\n", encoding="utf-8", ) config = load_extractor_config(path) assert config.cache_path == Path(".cache/media") - assert config.for_platform("youtube").cookie_file == Path(".secrets/youtube.txt") + assert config.for_platform("youtube").proxy == "http://127.0.0.1:7890" def test_subtitle_parser_supports_ass_and_json3() -> None: @@ -112,25 +128,73 @@ def test_subtitle_parser_normalizes_and_removes_duplicates() -> None: def test_platform_collector_maps_metadata(tmp_path: Path) -> None: - collector = YouTubeCollector(ExtractorConfig(cache_path=tmp_path)) info = { "id": "M7lc1UVf-VE", "title": "Demo", "webpage_url": "https://www.youtube.com/watch?v=M7lc1UVf-VE", } - with patch.object(collector.client, "extract_info", return_value=info) as request: - resource = collector.discover(info["webpage_url"]) + + class Worker: + def execute(self, payload): + assert payload["operation"] == "extract" + return info + + def close(self): + pass + + service = MediaService(ExtractorConfig(cache_path=tmp_path), worker=Worker()) + resource = service.discover(info["webpage_url"]) assert resource.metadata.id == "M7lc1UVf-VE" - request.assert_called_once() -def test_cache_round_trip(tmp_path: Path) -> None: - cache = MediaCache(tmp_path) +def test_media_asset_removes_lease_on_close(tmp_path: Path) -> None: + root = tmp_path / "lease" + root.mkdir() + path = root / "audio.mp3" + path.write_bytes(b"audio") + metadata = VideoMetadata("id", "title", None, 1, None, "youtube", "url") + reference = AssetReference( + "asset", MediaType.AUDIO, datetime.now(UTC) + timedelta(minutes=1), metadata + ) + with MediaAsset(reference, path, root) as asset: + assert asset.path.read_bytes() == b"audio" + assert not root.exists() + + +def test_media_service_download_returns_expiring_asset(tmp_path: Path) -> None: + info = { + "id": "M7lc1UVf-VE", + "title": "Demo", + "webpage_url": "https://www.youtube.com/watch?v=M7lc1UVf-VE", + } + + class Worker: + def execute(self, payload): + if payload["operation"] == "extract": + return info + path = Path(payload["target_dir"]) / "M7lc1UVf-VE.mp3" + path.write_bytes(b"audio") + return {"requested_downloads": [{"filepath": str(path)}]} + + def close(self): + pass + + config = ExtractorConfig(cache_path=tmp_path / "cache", runtime_path=tmp_path) + service = MediaService(config, worker=Worker()) + asset = service.download_audio(info["webpage_url"], AudioRequest(codec="mp3")) + lease_root = asset.path.parent + assert asset.path.read_bytes() == b"audio" + asset.close() + assert not lease_root.exists() + + +def test_repository_round_trip(tmp_path: Path) -> None: + repository = MediaRepository(tmp_path) metadata = VideoMetadata( "id", "title", None, 10, None, VideoPlatform.YOUTUBE, "url" ) segments = (SubtitleSegment(0, 1, "text"),) - cache.save_metadata(metadata) - cache.save_transcript(metadata, segments) - assert cache.load_metadata(VideoPlatform.YOUTUBE, "id") == metadata - assert cache.load_transcript(VideoPlatform.YOUTUBE, "id") == segments + repository.save_metadata(metadata) + repository.save_transcript(metadata, segments) + assert repository.load_metadata(VideoPlatform.YOUTUBE, "id") == metadata + assert repository.load_transcript(VideoPlatform.YOUTUBE, "id") == segments diff --git a/uv.lock b/uv.lock index d2a0bad..9039561 100644 --- a/uv.lock +++ b/uv.lock @@ -24,6 +24,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + [[package]] name = "certifi" version = "2026.7.22" @@ -38,7 +47,7 @@ name = "cffi" version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pycparser" }, + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } wheels = [ @@ -149,6 +158,65 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "47.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" }, + { url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" }, + { url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" }, + { url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" }, + { url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" }, + { url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" }, + { url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/7aa18ad9c11bc87689affa5ce4368d884b517502d75739d475fc6f4a03c7/cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", size = 7904299, upload-time = "2026-04-24T19:53:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" }, + { url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" }, + { url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" }, + { url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" }, + { url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" }, + { url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" }, + { url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" }, + { url = "https://files.pythonhosted.org/packages/31/98/dc4ad376ac5f1a1a7d4a83f7b0c6f2bcad36b5d2d8f30aeb482d3a7d9582/cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", size = 3237158, upload-time = "2026-04-24T19:54:02.606Z" }, + { url = "https://files.pythonhosted.org/packages/bc/da/97f62d18306b5133468bc3f8cc73a3111e8cdc8cf8d3e69474d6e5fd2d1b/cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", size = 3758706, upload-time = "2026-04-24T19:54:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" }, + { url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" }, + { url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" }, + { url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" }, + { url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" }, + { url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" }, + { url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" }, + { url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" }, + { url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" }, + { url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" }, + { url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a0/928c9ce0d120a40a81aa99e3ba383e87337b9ac9ef9f6db02e4d7822424d/cryptography-47.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7", size = 3909893, upload-time = "2026-04-24T19:54:38.334Z" }, + { url = "https://files.pythonhosted.org/packages/81/75/d691e284750df5d9569f2b1ce4a00a71e1d79566da83b2b3e5549c84917f/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe", size = 4587867, upload-time = "2026-04-24T19:54:40.619Z" }, + { url = "https://files.pythonhosted.org/packages/07/d6/1b90f1a4e453009730b4545286f0b39bb348d805c11181fc31544e4f9a65/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475", size = 4627192, upload-time = "2026-04-24T19:54:42.849Z" }, + { url = "https://files.pythonhosted.org/packages/dc/53/cb358a80e9e359529f496870dd08c102aa8a4b5b9f9064f00f0d6ed5b527/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50", size = 4587486, upload-time = "2026-04-24T19:54:44.908Z" }, + { url = "https://files.pythonhosted.org/packages/8b/57/aaa3d53876467a226f9a7a82fd14dd48058ad2de1948493442dfa16e2ffd/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab", size = 4626327, upload-time = "2026-04-24T19:54:47.813Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9c/51f28c3550276bcf35660703ba0ab829a90b88be8cd98a71ef23c2413913/cryptography-47.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8", size = 3698916, upload-time = "2026-04-24T19:54:49.782Z" }, +] + [[package]] name = "curl-cffi" version = "0.15.0" @@ -255,6 +323,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -264,6 +344,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", size = 20837, upload-time = "2026-07-14T01:28:02.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", size = 11677, upload-time = "2026-07-14T01:28:01.59Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + [[package]] name = "markdown-it-py" version = "4.2.0" @@ -285,6 +428,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "more-itertools" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, +] + [[package]] name = "nodeenv" version = "1.10.0" @@ -299,7 +451,9 @@ name = "noteforge-cli" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "cryptography" }, { name = "httpx" }, + { name = "keyring" }, { name = "rich" }, { name = "typer" }, { name = "yt-dlp", extra = ["curl-cffi"] }, @@ -314,7 +468,9 @@ dev = [ [package.metadata] requires-dist = [ + { name = "cryptography", specifier = ">=45,<48" }, { name = "httpx", specifier = ">=0.28,<1" }, + { name = "keyring", specifier = ">=25,<27" }, { name = "rich", specifier = ">=13,<15" }, { name = "typer", specifier = ">=0.12,<1" }, { name = "yt-dlp", extras = ["curl-cffi"], specifier = ">=2026.7.4" }, @@ -416,6 +572,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/07/a89b539750a159d5101c4eb9fc84e2961f65cefbd5e0b7440b284471c0b0/python_discovery-1.5.1-py3-none-any.whl", hash = "sha256:ac07f44cade589d954e9d6a1e1468539fdddd2cf676beb51da73e0f156b7c932", size = 35752, upload-time = "2026-07-31T22:06:01.116Z" }, ] +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -509,6 +674,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/6c/93e26c22c5f78ff87363e07da49c84955affbeb1098bd1936bf3b3f293bf/ruff-0.16.2-py3-none-win_arm64.whl", hash = "sha256:d614e95cedf38a2053fd351c55b103ba30d017d61688fdbfd40ee0412852a99f", size = 11374065, upload-time = "2026-08-07T13:30:58.775Z" }, ] +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -570,3 +748,12 @@ wheels = [ curl-cffi = [ { name = "curl-cffi", marker = "implementation_name == 'cpython'" }, ] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +]