Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# AGENTS.md

## Project Overview

This is the **Local Smart Gallery**, a web application designed to scan, organize, and display a unified timeline of local photo and video files. The goal is to create a seamless browsing experience for media collections.

## Tech Stack

- **Backend**: Python with **FastAPI**.
- **Database**: SQLite with **SQLAlchemy**.
- **Video Processing**: **OpenCV** (`cv2`) for thumbnail generation and metadata extraction.
- **Server**: **Uvicorn**.
- **Frontend**: JavaScript with **React**.
- **API Client**: **axios**.
- **Build Tool**: Create React App.

## Project Structure

```
.
├── backend/
│ ├── venv/ # Python Virtual Environment
│ ├── cache/
│ │ └── thumbnails/ # Generated video thumbnails
│ ├── database.py # SQLAlchemy models and DB setup
│ ├── main.py # FastAPI application, API endpoints
│ └── scanner.py # Core logic for scanning media files
├── frontend/
│ ├── public/
│ └── src/
│ ├── App.js # Main gallery component
│ ├── Lightbox.js # Modal for viewing single media items
│ ├── App.css
│ └── ...
└── media/ # Default directory for user's media files
```

## How to Run Development Servers

**Important**: The backend and frontend servers must be running concurrently.

1. **Start Backend Server**:
- Ensure you are in the repository root.
- The Python virtual environment is located at `backend/venv`.
- Run the following command:
```bash
source backend/venv/bin/activate && uvicorn backend.main:app --host 0.0.0.0 --port 8000
```

2. **Start Frontend Server**:
- Ensure you are in the repository root.
- Run the following command:
```bash
cd frontend && npm start
```
- The frontend will be available at `http://localhost:3000` and will proxy API requests to the backend at `http://localhost:8000`.

## Key Logic Points

- **Scanner**: The `backend/scanner.py` script is the core of the media processing. It iterates through a target directory, identifies images and videos, extracts metadata (`creation_time`, `duration`), and generates thumbnails for videos using OpenCV. All metadata is stored in the SQLite database.
- **Video Streaming**: The `/api/media/stream/{item_id}` endpoint supports HTTP Range Requests (`206 Partial Content`) to ensure large video files can be streamed efficiently without being fully downloaded first. This is crucial for performance.
- **Thumbnails**: Video thumbnails are **not** generated on-the-fly. They are created once during the initial scan and stored in `backend/cache/thumbnails/`. The frontend loads these static thumbnail images for the grid view.
96 changes: 94 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,94 @@
# MyOwnPhotoView
view my own local photo
# Local Smart Gallery (支持视频版)

## 📖 项目简介 (Project Overview)

Local Smart Gallery 是一个本地智能媒体库,旨在为您混乱的本地照片和视频文件夹带来秩序。它会自动扫描指定目录下的媒体文件,提取元数据,并将它们统一按时间顺序呈现在一个美观、高效的网页界面中。

无论是您用相机拍摄的照片,还是手机录制的视频,此应用都会将它们无缝地融合在同一个时间轴上,重现您在特定事件(如漫展、旅行)中的完整回忆。

## ✨ 核心功能 (Features)

- **统一时间线**: 将照片 (`.jpg`, `.png`, `.heic`) 和视频 (`.mp4`, `.mov`, `.avi`) 混合排序在同一个视图中。
- **视频支持**:
- **自动生成缩略图**: 后端在扫描时使用 OpenCV 截取视频第一帧作为预览,避免前端加载大文件。
- **流式播放 (Streaming)**: 支持 HTTP Range Requests,即使是几百兆的大视频也能流畅拖动进度条,无需等待完整下载。
- **元数据提取**: 自动读取视频的创建时间和时长。
- **高效浏览**:
- **网格视图**: 瀑布流式展示所有媒体,视频文件会以 ▶️ 图标和时长角标进行区分。
- **详情视图 (Lightbox)**: 点击任意文件可进入大图/播放器模式,支持键盘左右键切换上一个/下一个。
- **简单的扫描机制**: 通过一个 API 请求即可启动对媒体文件夹的扫描和索引。

## 🛠️ 技术栈 (Tech Stack)

- **后端 (Backend)**:
- **框架**: Python & **FastAPI**
- **数据库**: SQLite & **SQLAlchemy**
- **视频处理**: **OpenCV** (`cv2`)
- **Web 服务器**: **Uvicorn**
- **前端 (Frontend)**:
- **框架**: **React** (via Create React App)
- **HTTP 请求**: **axios**
- **视频播放**: 原生 HTML5 `<video>` 标签

## 🚀 快速开始 (Getting Started)

### 先决条件 (Prerequisites)

- [Python](https://www.python.org/downloads/) (3.8 或更高版本)
- [Node.js](https://nodejs.org/) 和 [npm](https://www.npmjs.com/) (16.x 或更高版本)
- (可选) [Git](https://git-scm.com/)

### 本地安装与运行 (Installation & Running Locally)

1. **克隆仓库 (Clone the repository)**:
```bash
git clone <your-repository-url>
cd local-smart-gallery
```

2. **设置后端 (Setup Backend)**:
```bash
# 1. 创建并激活 Python 虚拟环境
python3 -m venv backend/venv
source backend/venv/bin/activate

# 2. 安装后端依赖
pip install -r requirements.txt
# (注意: 如果没有 requirements.txt, 请根据 AGENTS.md 手动安装)
# pip install fastapi "uvicorn[standard]" sqlalchemy aiosqlite opencv-python
```

3. **设置前端 (Setup Frontend)**:
```bash
# 1. 进入前端目录并安装依赖
cd frontend
npm install
cd ..
```

4. **放置您的媒体文件 (Place your media files)**:
- 在项目根目录下有一个 `media/` 文件夹。
- 将您想要展示的照片和视频文件复制到这里。

5. **启动应用 (Run the Application)**:

- **启动后端服务器**:
*打开一个终端窗口*
```bash
source backend/venv/bin/activate
uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload
```
服务器将在 `http://localhost:8000` 运行。

- **启动前端开发服务器**:
*打开一个新的终端窗口*
```bash
cd frontend
npm start
```
应用将在 `http://localhost:3000` 自动打开。

6. **开始使用 (Start Using)**:
- 打开浏览器,访问 `http://localhost:3000`。
- 点击页面顶部的 **"Scan Media"** 按钮,后端将开始索引您放在 `media/` 文件夹中的所有文件。
- 扫描完成后,您的照片和视频画廊将呈现在页面上。享受吧!
Empty file added backend/__init__.py
Empty file.
26 changes: 26 additions & 0 deletions backend/database.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime
from sqlalchemy.orm import sessionmaker
from sqlalchemy.ext.declarative import declarative_base
import datetime

DATABASE_URL = "sqlite:///./gallery.db"

engine = create_engine(
DATABASE_URL, connect_args={"check_same_thread": False}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

Base = declarative_base()

class MediaItem(Base):
__tablename__ = "media_items"

id = Column(String, primary_key=True, index=True)
filepath = Column(String, unique=True, index=True)
media_type = Column(String, default='image')
created_at = Column(DateTime, default=datetime.datetime.utcnow)
duration = Column(Integer, nullable=True) # Duration in seconds for videos
thumbnail_path = Column(String, nullable=True) # Path to the video thumbnail

def create_db_and_tables():
Base.metadata.create_all(bind=engine)
101 changes: 101 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
from fastapi import FastAPI, Depends, HTTPException, Request, Response
from fastapi.responses import StreamingResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session
from backend.database import create_db_and_tables, SessionLocal, MediaItem
from backend.scanner import scan_directory
import os
import re
import mimetypes

app = FastAPI()

app.mount("/thumbnails", StaticFiles(directory="backend/cache/thumbnails"), name="thumbnails")
app.mount("/media", StaticFiles(directory="backend/media"), name="media")

def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

@app.on_event("startup")
def on_startup():
create_db_and_tables()

@app.post("/api/scan")
def scan_media_endpoint(directory: str = "backend/media", db: Session = Depends(get_db)):
scan_directory(directory, db)
return {"message": "Scan completed."}

@app.get("/api/media")
def get_media_items(db: Session = Depends(get_db)):
return db.query(MediaItem).order_by(MediaItem.created_at.desc()).all()

@app.get("/api/media/stream/{item_id}")
async def stream_video(item_id: int, request: Request, db: Session = Depends(get_db)):
item = db.query(MediaItem).filter(MediaItem.id == item_id).first()
if not item or item.media_type != 'video':
raise HTTPException(status_code=404, detail="Video not found")

video_path = item.filepath
file_size = os.path.getsize(video_path)

mime_type, _ = mimetypes.guess_type(video_path)
if mime_type is None:
mime_type = "video/mp4"
range_header = request.headers.get('Range')

if range_header:
byte1, byte2 = 0, file_size - 1
range_match = re.search(r'bytes=(\d+)-(\d*)', range_header)
if range_match:
byte1 = int(range_match.group(1))
if range_match.group(2):
byte2 = int(range_match.group(2))

length = byte2 - byte1 + 1
headers = {
'Content-Range': f'bytes {byte1}-{byte2}/{file_size}',
'Content-Length': str(length),
'Accept-Ranges': 'bytes',
}

def file_iterator(path, offset, chunk_size):
with open(path, 'rb') as f:
f.seek(offset)
while True:
data = f.read(chunk_size)
if not data:
break
yield data

return StreamingResponse(
file_iterator(video_path, byte1, 65536),
status_code=206,
headers=headers,
media_type=mime_type
)
else:
def file_iterator(path, chunk_size):
with open(path, 'rb') as f:
while True:
data = f.read(chunk_size)
if not data:
break
yield data

headers = {
'Content-Length': str(file_size),
'Accept-Ranges': 'bytes',
}
return StreamingResponse(
file_iterator(video_path, 65536),
headers=headers,
media_type=mime_type
)

@app.get("/")
def read_root():
return {"message": "Welcome to Local Smart Gallery"}
5 changes: 5 additions & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
fastapi
uvicorn[standard]
sqlalchemy
aiosqlite
opencv-python
89 changes: 89 additions & 0 deletions backend/scanner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import os
import cv2
import datetime
from pathlib import Path
from sqlalchemy.orm import Session
from backend.database import MediaItem, SessionLocal
import hashlib

SUPPORTED_IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.heic']
SUPPORTED_VIDEO_EXTENSIONS = ['.mp4', '.mov', '.avi']
THUMBNAIL_DIR = Path("backend/cache/thumbnails")

def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

def get_creation_time(filepath: Path) -> datetime.datetime:
return datetime.datetime.fromtimestamp(filepath.stat().st_mtime)

def create_video_thumbnail(video_path: Path, thumbnail_path: Path):
try:
vid_cap = cv2.VideoCapture(str(video_path))
success, image = vid_cap.read()
if success:
cv2.imwrite(str(thumbnail_path), image)
vid_cap.release()
except Exception as e:
print(f"Error creating thumbnail for {video_path}: {e}")


def get_video_duration(video_path: Path) -> int:
try:
vid_cap = cv2.VideoCapture(str(video_path))
fps = vid_cap.get(cv2.CAP_PROP_FPS)
frame_count = vid_cap.get(cv2.CAP_PROP_FRAME_COUNT)
vid_cap.release()
return int(frame_count / fps) if fps > 0 else 0
except Exception as e:
print(f"Error getting duration for {video_path}: {e}")
return 0

def scan_directory(directory: str, db: Session):
print(f"Scanning directory: {directory}")
base_dir = Path(directory)
for root, _, files in os.walk(directory):
for filename in files:
filepath = Path(root) / filename
ext = filepath.suffix.lower()

# Use a unique identifier for the filepath in the DB to avoid collisions
unique_id = hashlib.md5(str(filepath).encode()).hexdigest()
db_item = db.query(MediaItem).filter(MediaItem.id == unique_id).first()

if db_item:
continue

web_path = f"/media/{filepath.relative_to(base_dir)}"

if ext in SUPPORTED_IMAGE_EXTENSIONS:
item = MediaItem(
id=unique_id,
filepath=web_path,
media_type='image',
created_at=get_creation_time(filepath)
)
db.add(item)

elif ext in SUPPORTED_VIDEO_EXTENSIONS:
thumbnail_filename = f"{unique_id}.jpg"
thumbnail_path = THUMBNAIL_DIR / thumbnail_filename
create_video_thumbnail(filepath, thumbnail_path)

item = MediaItem(
id=unique_id,
filepath=str(filepath), # Store the real path for streaming
media_type='video',
created_at=get_creation_time(filepath),
duration=get_video_duration(filepath),
thumbnail_path=f"/thumbnails/{thumbnail_filename}"
)
db.add(item)
db.commit()

def start_scan(directory: str):
db = next(get_db())
scan_directory(directory, db)
Loading