moviefinder-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ """MovieFinder package."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ main()
@@ -0,0 +1,125 @@
1
+ import json
2
+ import sqlite3
3
+ import time
4
+ from pathlib import Path
5
+ from typing import Any, Dict, List, Optional, Tuple
6
+
7
+
8
+ class SqliteCache:
9
+ def __init__(self, db_path: str = "data/moviefinder.sqlite3") -> None:
10
+ self.db_path = Path(db_path)
11
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
12
+ self._ensure_schema()
13
+
14
+ def _connect(self) -> sqlite3.Connection:
15
+ connection = sqlite3.connect(str(self.db_path))
16
+ connection.row_factory = sqlite3.Row
17
+ return connection
18
+
19
+ def _ensure_schema(self) -> None:
20
+ with self._connect() as connection:
21
+ connection.execute(
22
+ """
23
+ CREATE TABLE IF NOT EXISTS cache_entries (
24
+ namespace TEXT NOT NULL,
25
+ cache_key TEXT NOT NULL,
26
+ payload TEXT NOT NULL,
27
+ created_at REAL NOT NULL,
28
+ expires_at REAL NOT NULL,
29
+ PRIMARY KEY (namespace, cache_key)
30
+ )
31
+ """
32
+ )
33
+ connection.execute(
34
+ """
35
+ CREATE INDEX IF NOT EXISTS idx_cache_entries_expires_at
36
+ ON cache_entries(expires_at)
37
+ """
38
+ )
39
+
40
+ def get_json(self, namespace: str, cache_key: str) -> Tuple[Optional[Any], bool]:
41
+ payload, hit, _expired = self.get_json_with_meta(namespace, cache_key)
42
+ return payload, hit
43
+
44
+ def get_json_with_meta(
45
+ self, namespace: str, cache_key: str, allow_expired: bool = False
46
+ ) -> Tuple[Optional[Any], bool, bool]:
47
+ now = time.time()
48
+ with self._connect() as connection:
49
+ row = connection.execute(
50
+ """
51
+ SELECT payload, expires_at
52
+ FROM cache_entries
53
+ WHERE namespace = ? AND cache_key = ?
54
+ """,
55
+ (namespace, cache_key),
56
+ ).fetchone()
57
+ if row is None:
58
+ return None, False, False
59
+ expired = float(row["expires_at"]) < now
60
+ if expired and not allow_expired:
61
+ return None, False, True
62
+ return json.loads(row["payload"]), True, expired
63
+
64
+ def set_json(
65
+ self, namespace: str, cache_key: str, value: Any, ttl_seconds: int
66
+ ) -> None:
67
+ now = time.time()
68
+ with self._connect() as connection:
69
+ connection.execute(
70
+ """
71
+ INSERT INTO cache_entries
72
+ (namespace, cache_key, payload, created_at, expires_at)
73
+ VALUES (?, ?, ?, ?, ?)
74
+ ON CONFLICT(namespace, cache_key) DO UPDATE SET
75
+ payload = excluded.payload,
76
+ created_at = excluded.created_at,
77
+ expires_at = excluded.expires_at
78
+ """,
79
+ (
80
+ namespace,
81
+ cache_key,
82
+ json.dumps(value, ensure_ascii=False),
83
+ now,
84
+ now + ttl_seconds,
85
+ ),
86
+ )
87
+
88
+ def iter_json(
89
+ self, namespace: str, allow_expired: bool = False
90
+ ) -> List[Tuple[str, Any, bool]]:
91
+ now = time.time()
92
+ with self._connect() as connection:
93
+ rows = connection.execute(
94
+ """
95
+ SELECT cache_key, payload, expires_at
96
+ FROM cache_entries
97
+ WHERE namespace = ?
98
+ ORDER BY created_at DESC
99
+ """,
100
+ (namespace,),
101
+ ).fetchall()
102
+
103
+ entries: List[Tuple[str, Any, bool]] = []
104
+ for row in rows:
105
+ expired = float(row["expires_at"]) < now
106
+ if expired and not allow_expired:
107
+ continue
108
+ entries.append((str(row["cache_key"]), json.loads(row["payload"]), expired))
109
+ return entries
110
+
111
+ def clear(self) -> None:
112
+ with self._connect() as connection:
113
+ connection.execute("DELETE FROM cache_entries")
114
+
115
+ def stats(self) -> Dict[str, int]:
116
+ with self._connect() as connection:
117
+ rows = connection.execute(
118
+ """
119
+ SELECT namespace, COUNT(*) AS count
120
+ FROM cache_entries
121
+ GROUP BY namespace
122
+ ORDER BY namespace
123
+ """
124
+ ).fetchall()
125
+ return {str(row["namespace"]): int(row["count"]) for row in rows}
moviefinder_cli/cli.py ADDED
@@ -0,0 +1,82 @@
1
+ import argparse
2
+ import json
3
+ import sys
4
+ from typing import Any, Dict
5
+
6
+ from .markdown import (
7
+ render_cache_stats_markdown,
8
+ render_error_markdown,
9
+ render_search_markdown,
10
+ )
11
+ from .server import run_server
12
+ from .service import MovieFinderService
13
+
14
+
15
+ def _add_output_format(parser: argparse.ArgumentParser) -> None:
16
+ parser.add_argument(
17
+ "--format",
18
+ choices=("markdown", "json"),
19
+ default="markdown",
20
+ help="Output format. Defaults to markdown.",
21
+ )
22
+
23
+
24
+ def main() -> None:
25
+ parser = argparse.ArgumentParser(prog="moviefinder")
26
+ subparsers = parser.add_subparsers(dest="command", required=True)
27
+
28
+ search_parser = subparsers.add_parser("search", help="Search rrdynb resources")
29
+ search_parser.add_argument("keyword")
30
+ search_parser.add_argument("--limit", type=int, default=10)
31
+ search_parser.add_argument("--refresh", action="store_true")
32
+ _add_output_format(search_parser)
33
+
34
+ cache_parser = subparsers.add_parser(
35
+ "cache-stats", help="Show SQLite cache entry counts"
36
+ )
37
+ _add_output_format(cache_parser)
38
+
39
+ serve_parser = subparsers.add_parser("serve", help="Start HTTP API")
40
+ serve_parser.add_argument("--host", default="127.0.0.1")
41
+ serve_parser.add_argument("--port", type=int, default=8000)
42
+
43
+ args = parser.parse_args()
44
+
45
+ if args.command == "serve":
46
+ run_server(args.host, args.port)
47
+ return
48
+
49
+ try:
50
+ service = MovieFinderService()
51
+ if args.command == "search":
52
+ payload = service.search_movies(args.keyword, args.limit, args.refresh)
53
+ elif args.command == "cache-stats":
54
+ payload = {"cache": service.cache_stats()}
55
+ else:
56
+ parser.error("unknown command")
57
+ return
58
+ except Exception as exc:
59
+ print(_format_error(str(exc), args.format), file=sys.stderr)
60
+ raise SystemExit(1)
61
+
62
+ print(_format_payload(args.command, payload, args.format))
63
+
64
+
65
+ def _format_payload(command: str, payload: Dict[str, Any], output_format: str) -> str:
66
+ if output_format == "json":
67
+ return json.dumps(payload, ensure_ascii=False, indent=2)
68
+ if command == "search":
69
+ return render_search_markdown(payload)
70
+ if command == "cache-stats":
71
+ return render_cache_stats_markdown(payload)
72
+ return json.dumps(payload, ensure_ascii=False, indent=2)
73
+
74
+
75
+ def _format_error(message: str, output_format: str) -> str:
76
+ if output_format == "json":
77
+ return json.dumps({"error": message}, ensure_ascii=False, indent=2)
78
+ return render_error_markdown(message)
79
+
80
+
81
+ if __name__ == "__main__":
82
+ main()
@@ -0,0 +1,129 @@
1
+ from typing import Any, Dict, Iterable, List
2
+
3
+
4
+ def render_search_markdown(payload: Dict[str, Any]) -> str:
5
+ lines: List[str] = []
6
+ keyword = payload.get("keyword") or ""
7
+ items = payload.get("items") or []
8
+ cache = payload.get("cache") or {}
9
+
10
+ lines.append(f"# 搜索结果:{_escape_heading(keyword)}")
11
+ lines.append("")
12
+ cache_label = "过期缓存兜底" if cache.get("stale") else ("命中" if cache.get("hit") else "未命中")
13
+ lines.extend(
14
+ [
15
+ f"- 来源:{_plain(payload.get('source'))}",
16
+ f"- 搜索页:{_link(payload.get('source_url'))}",
17
+ f"- 结果数:{payload.get('total', len(items))}",
18
+ f"- 缓存:{cache_label}",
19
+ ]
20
+ )
21
+ if cache.get("warning"):
22
+ lines.append(f"- 警告:{_plain(cache.get('warning'))}")
23
+
24
+ errors = payload.get("errors") or []
25
+ if errors:
26
+ lines.append(f"- 详情解析错误:{len(errors)} 条")
27
+
28
+ if not items:
29
+ lines.append("")
30
+ lines.append("没有搜索到结果。")
31
+ return "\n".join(lines)
32
+
33
+ for index, item in enumerate(items, start=1):
34
+ lines.append("")
35
+ lines.append(f"## {index}. {_escape_heading(item.get('title') or '未命名影片')}")
36
+ lines.append("")
37
+ lines.extend(
38
+ _table(
39
+ ["字段", "值"],
40
+ [
41
+ ("分类", item.get("category")),
42
+ ("发布日期", item.get("published_at")),
43
+ ("豆瓣评分", item.get("douban_score")),
44
+ ("IMDB 评分", item.get("imdb_score")),
45
+ ("IMDb ID", item.get("imdb_id")),
46
+ ("详情页", _link(item.get("detail_url"))),
47
+ ("海报", _link(item.get("poster_url"))),
48
+ ],
49
+ )
50
+ )
51
+
52
+ summary = item.get("summary")
53
+ if summary:
54
+ lines.append("")
55
+ lines.append("### 简介")
56
+ lines.append("")
57
+ lines.append(_paragraph(summary))
58
+
59
+ resources = item.get("resources") or []
60
+ lines.append("")
61
+ lines.append("### 网盘资源")
62
+ lines.append("")
63
+ if resources:
64
+ rows = []
65
+ for resource in resources:
66
+ rows.append(
67
+ (
68
+ resource.get("platform"),
69
+ _link(resource.get("url")),
70
+ resource.get("access_code"),
71
+ resource.get("url_password"),
72
+ )
73
+ )
74
+ lines.extend(_table(["平台", "地址", "提取码", "URL 密码"], rows))
75
+ else:
76
+ lines.append("未解析到网盘资源。")
77
+
78
+ return "\n".join(lines)
79
+
80
+
81
+ def render_cache_stats_markdown(payload: Dict[str, Any]) -> str:
82
+ cache = payload.get("cache") or {}
83
+ lines = ["# 缓存统计", ""]
84
+ if not cache:
85
+ lines.append("暂无缓存记录。")
86
+ return "\n".join(lines)
87
+ rows = [(namespace, count) for namespace, count in sorted(cache.items())]
88
+ lines.extend(_table(["命名空间", "数量"], rows))
89
+ return "\n".join(lines)
90
+
91
+
92
+ def render_error_markdown(message: str) -> str:
93
+ return "\n".join(["# 错误", "", _paragraph(message)])
94
+
95
+
96
+ def _table(headers: List[str], rows: Iterable[Iterable[Any]]) -> List[str]:
97
+ table = [
98
+ "| " + " | ".join(_cell(header) for header in headers) + " |",
99
+ "| " + " | ".join("---" for _ in headers) + " |",
100
+ ]
101
+ for row in rows:
102
+ table.append("| " + " | ".join(_cell(value) for value in row) + " |")
103
+ return table
104
+
105
+
106
+ def _cell(value: Any) -> str:
107
+ text = _plain(value)
108
+ return text.replace("|", "\\|").replace("\n", "<br>")
109
+
110
+
111
+ def _plain(value: Any) -> str:
112
+ if value is None or value == "":
113
+ return "-"
114
+ return str(value).strip()
115
+
116
+
117
+ def _paragraph(value: Any) -> str:
118
+ return " ".join(_plain(value).split())
119
+
120
+
121
+ def _escape_heading(value: Any) -> str:
122
+ return _plain(value).replace("#", "\\#").strip()
123
+
124
+
125
+ def _link(url: Any) -> str:
126
+ value = _plain(url)
127
+ if value == "-":
128
+ return "-"
129
+ return f"[打开]({value})"
@@ -0,0 +1 @@
1
+ """MovieFinder MCP integration."""
@@ -0,0 +1,143 @@
1
+ """MovieFinder MCP server.
2
+
3
+ The server exposes rrdynb movie search to Agent clients through MCP.
4
+ """
5
+
6
+ import argparse
7
+ import json
8
+ import logging
9
+ import os
10
+
11
+ from fastmcp import FastMCP
12
+
13
+ from moviefinder_cli import __version__
14
+ from moviefinder_cli.markdown import (
15
+ render_cache_stats_markdown,
16
+ render_search_markdown,
17
+ )
18
+ from moviefinder_cli.service import MovieFinderService
19
+
20
+
21
+ mcp = FastMCP(
22
+ name="moviefinder",
23
+ instructions="""MovieFinder MCP - Search movie resources from rrdynb.
24
+
25
+ Tools available:
26
+ - search_movies(query, limit=5, refresh=False, output_format="markdown"): Search movies and return synopsis, ratings, detail URLs, and cloud-drive resource links.
27
+ - movie_cache_stats(output_format="markdown"): Show local SQLite cache counts.
28
+
29
+ Recommended workflow:
30
+ 1. Use search_movies() with a specific movie or TV title.
31
+ 2. Read the Markdown output and choose the most relevant result by title/year.
32
+ 3. Use the returned detail URL and cloud-drive links as source references.
33
+
34
+ Notes:
35
+ - MovieFinder only reads public pages and does not download media files.
36
+ - If the live search page is blocked temporarily, cached results may be returned with a warning.
37
+ """,
38
+ )
39
+
40
+ logger = logging.getLogger("moviefinder_cli.mcp")
41
+
42
+
43
+ @mcp.tool()
44
+ def search_movies(
45
+ query: str,
46
+ limit: int = 5,
47
+ refresh: bool = False,
48
+ output_format: str = "markdown",
49
+ ) -> str:
50
+ """Search movie resources by keyword.
51
+
52
+ Args:
53
+ query: Movie, TV, anime, actor, or title keyword.
54
+ limit: Maximum number of results to return. Values above 50 are capped.
55
+ refresh: Force live refresh instead of using fresh cache.
56
+ output_format: "markdown" or "json".
57
+
58
+ Returns:
59
+ Search results containing synopsis, Douban/IMDB ratings, and cloud-drive links.
60
+ """
61
+ try:
62
+ payload = MovieFinderService().search_movies(query, limit, refresh)
63
+ except Exception as exc:
64
+ return _format_error(exc, output_format)
65
+
66
+ if output_format == "json":
67
+ return json.dumps(payload, ensure_ascii=False, indent=2)
68
+ return render_search_markdown(payload)
69
+
70
+
71
+ @mcp.tool()
72
+ def movie_cache_stats(output_format: str = "markdown") -> str:
73
+ """Return local MovieFinder cache statistics.
74
+
75
+ Args:
76
+ output_format: "markdown" or "json".
77
+
78
+ Returns:
79
+ Cache entry counts grouped by namespace.
80
+ """
81
+ payload = {"cache": MovieFinderService().cache_stats()}
82
+ if output_format == "json":
83
+ return json.dumps(payload, ensure_ascii=False, indent=2)
84
+ return render_cache_stats_markdown(payload)
85
+
86
+
87
+ def main() -> None:
88
+ parser = argparse.ArgumentParser(
89
+ description="MovieFinder MCP Server",
90
+ formatter_class=argparse.RawDescriptionHelpFormatter,
91
+ )
92
+ parser.add_argument(
93
+ "--transport",
94
+ "-t",
95
+ choices=["stdio", "sse"],
96
+ default=os.environ.get("MOVIEFINDER_MCP_TRANSPORT", "stdio"),
97
+ help="Transport protocol (default: stdio)",
98
+ )
99
+ parser.add_argument(
100
+ "--host",
101
+ "-H",
102
+ default=os.environ.get("MOVIEFINDER_MCP_HOST", "127.0.0.1"),
103
+ help="Host to bind for SSE (default: 127.0.0.1)",
104
+ )
105
+ parser.add_argument(
106
+ "--port",
107
+ "-p",
108
+ type=int,
109
+ default=int(os.environ.get("MOVIEFINDER_MCP_PORT", "8000")),
110
+ help="Port for SSE transport (default: 8000)",
111
+ )
112
+ parser.add_argument(
113
+ "--debug",
114
+ action="store_true",
115
+ default=os.environ.get("MOVIEFINDER_MCP_DEBUG", "").lower() == "true",
116
+ help="Enable debug logging",
117
+ )
118
+
119
+ args = parser.parse_args()
120
+
121
+ if args.debug:
122
+ logging.basicConfig(
123
+ level=logging.DEBUG,
124
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
125
+ )
126
+ logger.setLevel(logging.DEBUG)
127
+ logger.debug("Starting MovieFinder MCP v%s", __version__)
128
+
129
+ if args.transport == "stdio":
130
+ mcp.run()
131
+ else:
132
+ mcp.run(transport="sse", host=args.host, port=args.port)
133
+
134
+
135
+ def _format_error(exc: Exception, output_format: str) -> str:
136
+ message = str(exc)
137
+ if output_format == "json":
138
+ return json.dumps({"error": message}, ensure_ascii=False, indent=2)
139
+ return f"# 错误\n\n{message}"
140
+
141
+
142
+ if __name__ == "__main__":
143
+ main()
@@ -0,0 +1,53 @@
1
+ from dataclasses import asdict, dataclass, field
2
+ from typing import Any, Dict, List, Optional
3
+
4
+
5
+ @dataclass
6
+ class ResourceLink:
7
+ platform: str
8
+ url: str
9
+ label: str = ""
10
+ access_code: Optional[str] = None
11
+ url_password: Optional[str] = None
12
+
13
+ def to_dict(self) -> Dict[str, Any]:
14
+ return asdict(self)
15
+
16
+
17
+ @dataclass
18
+ class MovieResult:
19
+ title: str
20
+ detail_url: str
21
+ category: Optional[str] = None
22
+ published_at: Optional[str] = None
23
+ summary: Optional[str] = None
24
+ intro: Optional[str] = None
25
+ douban_score: Optional[str] = None
26
+ imdb_score: Optional[str] = None
27
+ imdb_id: Optional[str] = None
28
+ poster_url: Optional[str] = None
29
+ resources: List[ResourceLink] = field(default_factory=list)
30
+ source: str = "rrdynb"
31
+
32
+ def merge_detail(self, detail: "MovieResult") -> "MovieResult":
33
+ self.title = detail.title or self.title
34
+ self.published_at = detail.published_at or self.published_at
35
+ self.summary = detail.summary or self.summary
36
+ self.intro = detail.intro or self.intro
37
+ self.imdb_id = detail.imdb_id or self.imdb_id
38
+ self.poster_url = detail.poster_url or self.poster_url
39
+ self.resources = detail.resources or self.resources
40
+ return self
41
+
42
+ def to_dict(self) -> Dict[str, Any]:
43
+ data = asdict(self)
44
+ data["resources"] = [resource.to_dict() for resource in self.resources]
45
+ return data
46
+
47
+ @classmethod
48
+ def from_dict(cls, data: Dict[str, Any]) -> "MovieResult":
49
+ payload = dict(data)
50
+ payload["resources"] = [
51
+ ResourceLink(**resource) for resource in payload.get("resources", [])
52
+ ]
53
+ return cls(**payload)