init
This commit is contained in:
8
.claude/settings.local.json
Normal file
8
.claude/settings.local.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(python3 -m venv venv)",
|
||||||
|
"Bash(./venv/bin/pip install:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
assets/images/*
|
||||||
|
assets/videos/*
|
||||||
25
PROMPT-OLD.md
Normal file
25
PROMPT-OLD.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# 프로젝트 구현 기록 (Prompts) - 원본
|
||||||
|
|
||||||
|
본 파일은 프로젝트 진행 과정에서 전달된 사용자의 주요 요구 사항들을 시간 순서대로 정리한 기록입니다.
|
||||||
|
|
||||||
|
### 1. 프로젝트 소개 파일 수정
|
||||||
|
- **요구 사항**: `README.md` 파일에는 구현할 프로그램의 설명과 요구사항이 작성되어 있어. 견고한 프로그램 구현을 위해 해당 파일의 내용을 수정해서 `REQUIREMENTS.md` 파일에 저장해줘.
|
||||||
|
|
||||||
|
### 2. 프로젝트 구현
|
||||||
|
- **요구 사항**: 작성된 `REQUIREMENTS.md` 파일의 내용을 참고해서 프로그램 초안을 구현해줘.
|
||||||
|
|
||||||
|
### 3. 초기 레포트 생성 및 분석
|
||||||
|
- **요구 사항**: 구현한 프로그램에 대한 설명, 실행법, curl을 이용한 테스트 방법 등의 내용을 `REPORT.md` 파일에 작성해줘.
|
||||||
|
|
||||||
|
### 4. YouTube 다운로드 기능 추가
|
||||||
|
- **요구 사항**: youtube 링크를 전달하면 해당 링크에서 파일을 다운로드 할 수 있도록 youtube 링크 전달 및 다운로드를 위한 API를 만들어줘. 그리고 이를 이용할 수 있도록 web interface를 수정, `REPORT.md` 파일 또한 함께 수정해줘.
|
||||||
|
|
||||||
|
### 5. 실시간 스트리밍 지원
|
||||||
|
- **요구 사항**: 다운로드 받은 영상의 경우 실시간 스트리밍이 가능하도록 코드를 변경해줘.
|
||||||
|
|
||||||
|
### 6. 웹 UI 모던화 및 스타일 개선
|
||||||
|
- **요구 사항**: web ui가 너무 심플하고, 사용하기 불편해. 모던하고, 파스텔 컬러 스타일의 사용하기 편한 UI로 웹을 전반적으로 수정해줘.
|
||||||
|
|
||||||
|
### 7. 파일 삭제 기능 구현
|
||||||
|
- **요구 사항**: 업로드한 파일을 삭제할 수 있는 기능을 작성해줘.
|
||||||
|
|
||||||
63
PROMPT.md
Normal file
63
PROMPT.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# 효율적인 프롬프트 작성 가이드 및 프로젝트 기록
|
||||||
|
|
||||||
|
이 문서는 프로젝트 개발 과정에서의 요구사항을 **Prompt Engineering** 관점에서 재구성한 가이드입니다. 각 기능 구현 시 모델에게 전달하면 가장 효과적인 구조를 채택했습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 💡 시스템 기본 페르소나 (System Role)
|
||||||
|
> "너는 FastAPI와 현대적인 Frontend 기술에 능숙한 시니어 풀스택 개발자야. 보안(경로 탐색 방지), 코드 효율성, 그리고 사용자 친화적인 UI/UX를 최우선으로 고려해서 코드를 작성해줘."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1. 요구사항 분석 및 구체화 (Requirement Engineering)
|
||||||
|
**[Prompt]**
|
||||||
|
- **Context**: `README.md` 파일에 프로그램 설명과 기초 요구사항이 작성되어 있어.
|
||||||
|
- **Task**: 견고하고 전문적인 프로그램 구현을 위해 이 내용을 분석하고, 상세한 소프트웨어 요구사항 명세서(SRS)를 작성해서 `REQUIREMENTS.md` 파일에 저장해줘.
|
||||||
|
- **Output**: 기능적/비기능적 요구사항, 기술 스택, 디렉토리 구조가 포함된 명세서.
|
||||||
|
|
||||||
|
### 2. 초기 프로토타입 구현 (Prototype Implementation)
|
||||||
|
**[Prompt]**
|
||||||
|
- **Context**: 작성된 `REQUIREMENTS.md` 명세서를 기반으로 프로젝트를 시작하려 해.
|
||||||
|
- **Task**: 명세서의 모든 요구사항을 충족하는 FastAPI 애플리케이션의 초안 코드를 작성해줘.
|
||||||
|
- **Constraints**:
|
||||||
|
- 파일 업로드/목록 조회/다운로드 API 포함.
|
||||||
|
- 파일 타입별(이미지/비디오) 자동 분류 저장 로직 구현.
|
||||||
|
- 메타데이터는 `metadata.json`으로 관리할 것.
|
||||||
|
|
||||||
|
### 3. 프로젝트 분석 및 문서화 (Documentation)
|
||||||
|
**[Prompt]**
|
||||||
|
- **Task**: 현재 구현된 코드베이스를 분석하고 사용자용 `REPORT.md`를 작성해줘.
|
||||||
|
- **Instructions**:
|
||||||
|
1. 핵심 기능 요약 및 실행 방법 안내.
|
||||||
|
2. `curl`을 이용한 API 엔드포인트별 테스트 명령어 예시 포함.
|
||||||
|
|
||||||
|
### 4. YouTube 연동 기능 (External Integration)
|
||||||
|
**[Prompt]**
|
||||||
|
- **Task**: YouTube URL을 통해 영상을 서버로 직접 다운로드하는 기능을 추가해줘.
|
||||||
|
- **Requirements**:
|
||||||
|
- `yt-dlp` 라이브러리를 사용하고, 결과물을 `assets/videos`에 저장.
|
||||||
|
- Web UI에 URL 입력 폼과 처리 상태 표시 기능을 통합할 것.
|
||||||
|
|
||||||
|
### 5. 스트리밍 엔진 최적화 (Streaming & Range Requests)
|
||||||
|
**[Prompt]**
|
||||||
|
- **Task**: 대용량 영상의 실시간 스트리밍(Seeking)이 가능하도록 개선해줘.
|
||||||
|
- **Technical Detail**: FastAPI의 `FileResponse`가 HTTP Range Requests를 지원하도록 설정하고, 브라우저 내 재생을 위해 `inline` 콘텐츠 배치를 적용할 것.
|
||||||
|
|
||||||
|
### 6. UI/UX 현대화 (Frontend Redesign)
|
||||||
|
**[Prompt]**
|
||||||
|
- **Role**: 전문 UI/UX 디자이너 및 프론트엔드 개발자.
|
||||||
|
- **Task**: 기존의 기본적인 UI를 현대적인 파스텔 톤 스타일로 전면 개편해줘.
|
||||||
|
- **Style Guide**: 카드형 레이아웃, Google Fonts (Poppins), 반응형 디자인, 작업 상태 애니메이션(Loader) 적용.
|
||||||
|
|
||||||
|
### 7. 리소스 관리 및 삭제 (Resource Management)
|
||||||
|
**[Prompt]**
|
||||||
|
- **Task**: 서버 리소스 관리를 위해 파일 삭제 기능을 추가해줘.
|
||||||
|
- **Constraints**: 실제 파일 삭제와 메타데이터 제거를 동시 처리하고, UI에서는 삭제 전 사용자 확인 절차를 둘 것.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🚀 효율적인 프롬프트 전달 팁
|
||||||
|
1. **역할 정의 (Role)**: 전문가 페르소나를 지정하세요.
|
||||||
|
2. **구체적인 제약 조건 (Constraints)**: 기술적 한계나 필수 라이브러리를 명시하세요.
|
||||||
|
3. **단계별 지시 (Step-by-Step)**: 복잡한 작업은 논리적 순서로 나누어 요청하세요.
|
||||||
|
4. **출력 형식 지정 (Output Format)**: 결과물의 형태(Markdown, JSON, 특정 코드 스타일 등)를 정의하세요.
|
||||||
15
README.md
Normal file
15
README.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# 프로젝트 개요
|
||||||
|
- fastapi를 이용한 파일 업로드/다운로드 프로그램 작성
|
||||||
|
|
||||||
|
# 프로젝트 요구사항
|
||||||
|
|
||||||
|
- fastapi를 통해 파일 업로드 및 다운로드를 위한 api 구현
|
||||||
|
- 업로드한 파일은 파일의 타입에 따라 assets/videos 또는 assets/images에 저장
|
||||||
|
- 저장된 파일의 정보 (파일 이름, 타입, 업로드 날짜)는 별도 json 파일을 이용하여 관리
|
||||||
|
- API 인터페이스 정의
|
||||||
|
- 파일 업로드: /api/files/upload
|
||||||
|
- 파일 다운로드: /api/files/download
|
||||||
|
- static (웹 (html, css, javascript) 서빙) : /web
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
99
REPORT.md
Normal file
99
REPORT.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# 프로젝트 결과 보고서: FastAPI 파일 관리 시스템
|
||||||
|
|
||||||
|
## 1. 프로그램 개요
|
||||||
|
본 프로그램은 **FastAPI**를 기반으로 한 웹 기반 파일 관리 시스템입니다. 사용자는 이미와 동영상 파일을 업로드하고, 서버에서 해당 파일을 다운로드하거나 목록을 확인할 수 있습니다.
|
||||||
|
|
||||||
|
### 주요 기능
|
||||||
|
- **파일 업로드 (`POST /api/files/upload`)**: 파일을 업로드하면 MIME 타입에 따라 `assets/images` 또는 `assets/videos` 폴더로 자동 분류하여 저장합니다.
|
||||||
|
- **파일 목록 조회 (`GET /api/files`)**: 서버에 저장된 모든 파일의 메타데이터(파일명, 타입, 업로드 날짜, 경로 등)를 JSON 형식으로 반환합니다.
|
||||||
|
- **파일 다운로드 (`GET /api/files/download`)**: 파일명을 파라미터로 전달하여 서버에 저장된 파일을 로컬로 다운로드할 수 있습니다.
|
||||||
|
- **파일 삭제 (`DELETE /api/files`)**: 서버에서 특정 파일을 삭제하고 메타데이터에서도 제거합니다.
|
||||||
|
- **메타데이터 관리**: 업로드된 파일의 정보는 `metadata.json` 파일에 기록되어 영구적으로 관리됩니다.
|
||||||
|
- **YouTube 다운로드 (`POST /api/files/youtube`)**: YouTube 링크를 전달받아 해당 영상을 서버로 다운로드하고 목록에 추가합니다.
|
||||||
|
- **실시간 스트리밍 지원**: `FileResponse`의 Range Requests 지원을 통해 대용량 영상 파일도 브라우저에서 원하는 시점으로 이동하며 끊김 없이 감상할 수 있습니다.
|
||||||
|
- **모던 파스텔 UI**: 사용자 친화적인 파스텔 톤의 카드 레이아웃과 반응형 디자인을 적용하여 모바일과 데스크톱 모두에서 쾌적하게 사용할 수 있습니다.
|
||||||
|
- **웹 인터페이스 서빙 (`/web`)**: `static/` 디렉토리의 HTML/JS 파일을 통해 사용자에게 웹 화면을 제공합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 실행 방법
|
||||||
|
|
||||||
|
### 환경 준비
|
||||||
|
Python 3.9 버전 이상이 필요합니다.
|
||||||
|
|
||||||
|
1. **가상환경 생성 및 활성화**
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate # Linux/macOS
|
||||||
|
# 또는
|
||||||
|
.venv\Scripts\activate # Windows
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **필수 패키지 설치**
|
||||||
|
```bash
|
||||||
|
pip install fastapi uvicorn python-multipart yt-dlp
|
||||||
|
```
|
||||||
|
|
||||||
|
### 서버 실행
|
||||||
|
```bash
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
서버는 기본적으로 `http://0.0.0.0:8000`에서 실행됩니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. curl을 이용한 테스트 방법
|
||||||
|
|
||||||
|
### A. 파일 업로드 테스트
|
||||||
|
`assets/images/dummy.png` 파일을 업로드하는 예시입니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:8000/api/files/upload" \
|
||||||
|
-H "accept: application/json" \
|
||||||
|
-H "Content-Type: multipart/form-data" \
|
||||||
|
-F "file=@assets/images/dummy.png" \
|
||||||
|
-F "description=테스트 이미지입니다" \
|
||||||
|
-F "uploader=홍길동"
|
||||||
|
```
|
||||||
|
|
||||||
|
### B. 파일 목록 조회 테스트
|
||||||
|
서버에 저장된 모든 파일의 정보를 확인합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET "http://localhost:8000/api/files"
|
||||||
|
```
|
||||||
|
|
||||||
|
### C. 파일 다운로드 테스트
|
||||||
|
`dummy.png`라는 이름의 파일을 다운로드하여 `downloaded_file.png`로 저장하는 예시입니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET "http://localhost:8000/api/files/download?filename=dummy.png" \
|
||||||
|
--output downloaded_file.png
|
||||||
|
```
|
||||||
|
|
||||||
|
### D. 파일 삭제 테스트
|
||||||
|
서버의 파일을 삭제하는 예시입니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X DELETE "http://localhost:8000/api/files?filename=dummy.png"
|
||||||
|
```
|
||||||
|
|
||||||
|
### E. YouTube 다운로드 테스트
|
||||||
|
YouTube 링크를 통해 영상을 다운로드하는 예시입니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:8000/api/files/youtube" \
|
||||||
|
-H "accept: application/json" \
|
||||||
|
-H "Content-Type: multipart/form-data" \
|
||||||
|
-F "url=https://www.youtube.com/watch?v=EXAMPLE_ID" \
|
||||||
|
-F "uploader=테스터"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 디렉토리 구조
|
||||||
|
- `main.py`: FastAPI 애플리케이션 엔트리 포인트
|
||||||
|
- `metadata.json`: 파일 메타데이터 저장소
|
||||||
|
- `assets/`: 업로드된 실제 파일 저장 (images/videos 분류)
|
||||||
|
- `static/`: 웹 프론트엔드 정적 파일
|
||||||
|
- `REQUIREMENTS.md`: 프로젝트 요구사항 명세서
|
||||||
69
REQUIREMENTS.md
Normal file
69
REQUIREMENTS.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# Detailed Software Requirements Specification
|
||||||
|
|
||||||
|
## 1. Project Overview
|
||||||
|
A web-based file management system using FastAPI that allows users to upload and download files. The system categorizes files by type and maintains metadata in a JSON file.
|
||||||
|
|
||||||
|
## 2. Functional Requirements
|
||||||
|
|
||||||
|
### 2.1 File Upload
|
||||||
|
- **Endpoint**: `POST /api/files/upload`
|
||||||
|
- **Description**: Accepts a file and its associated metadata.
|
||||||
|
- **Payload**: Multipart form-data containing:
|
||||||
|
- `file`: The actual file to upload.
|
||||||
|
- `description`: (Optional) A brief description of the file.
|
||||||
|
- `uploader`: (Optional) Name of the person uploading.
|
||||||
|
- **Storage Logic**:
|
||||||
|
- Images (e.g., JPG, PNG, GIF) must be saved in `assets/images/`.
|
||||||
|
- Videos (e.g., MP4, AVI, MKV) must be saved in `assets/videos/`.
|
||||||
|
- *Optional: Handle other file types or return an error if unsupported.*
|
||||||
|
- **Metadata Management**:
|
||||||
|
- Upon successful upload, record the following in `metadata.json`:
|
||||||
|
- `filename`: Original or sanitized filename.
|
||||||
|
- `type`: MIME type or category (image/video).
|
||||||
|
- `upload_date`: ISO 8601 formatted timestamp.
|
||||||
|
- `file_path`: Relative path to the stored file.
|
||||||
|
- `description`: Provided description.
|
||||||
|
- `uploader`: Provided uploader name.
|
||||||
|
|
||||||
|
### 2.2 File List Retrieval
|
||||||
|
- **Endpoint**: `GET /api/files`
|
||||||
|
- **Description**: Returns a list of all uploaded files and their metadata.
|
||||||
|
- **Response**: JSON array containing metadata for each file stored in `metadata.json`.
|
||||||
|
|
||||||
|
### 2.3 File Download
|
||||||
|
- **Endpoint**: `GET /api/files/download`
|
||||||
|
- **Parameters**: `filename` (query parameter).
|
||||||
|
- **Description**: Streams the requested file to the client.
|
||||||
|
- **Error Handling**: Return 404 if the file is not found in metadata or on disk.
|
||||||
|
|
||||||
|
### 2.3 Static Web Serving
|
||||||
|
- **Endpoint**: `/web`
|
||||||
|
- **Description**: Serve static HTML, CSS, and JavaScript files from a `static` directory to provide a user interface.
|
||||||
|
|
||||||
|
## 3. Technical Requirements
|
||||||
|
|
||||||
|
### 3.1 Backend
|
||||||
|
- **Framework**: FastAPI
|
||||||
|
- **Language**: Python 3.9+
|
||||||
|
- **Dependencies**: `python-multipart` (for file uploads), `uvicorn` (ASGI server).
|
||||||
|
|
||||||
|
### 3.2 Data Storage
|
||||||
|
- **Filesystem**: Local storage under `assets/` directory.
|
||||||
|
- **Metadata**: A single `metadata.json` file acting as a lightweight database.
|
||||||
|
|
||||||
|
### 3.3 Directory Structure
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── main.py # FastAPI application entry point
|
||||||
|
├── metadata.json # File metadata storage
|
||||||
|
├── assets/
|
||||||
|
│ ├── images/ # Uploaded image files
|
||||||
|
│ └── videos/ # Uploaded video files
|
||||||
|
├── static/ # Web frontend files (HTML/CSS/JS)
|
||||||
|
└── REQUIREMENTS.md # This document
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Non-Functional Requirements
|
||||||
|
- **Security**: Sanitize filenames to prevent path traversal attacks.
|
||||||
|
- **Performance**: Efficiently handle file streaming for downloads.
|
||||||
|
- **Maintainability**: Clear separation between API logic and file management.
|
||||||
15
backup.md
Normal file
15
backup.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# 프로젝트 개요
|
||||||
|
- fastapi를 이용한 파일 업로드/다운로드 프로그램 작성
|
||||||
|
|
||||||
|
# 프로젝트 요구사항
|
||||||
|
|
||||||
|
- fastapi를 통해 파일 업로드 및 다운로드를 위한 api 구현
|
||||||
|
- 업로드한 파일은 파일의 타입에 따라 assets/videos 또는 assets/images에 저장
|
||||||
|
- 저장된 파일의 정보 (파일 이름, 타입, 업로드 날짜)는 별도 json 파일을 이용하여 관리
|
||||||
|
- API 인터페이스 정의
|
||||||
|
- 파일 업로드: /api/files/upload
|
||||||
|
- 파일 다운로드: /api/files/download
|
||||||
|
- static (웹 (html, css, javascript) 서빙) : /web
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
187
main.py
Normal file
187
main.py
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import yt_dlp
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
import yt_dlp
|
||||||
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||||
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
BASE_DIR = Path(__file__).parent
|
||||||
|
ASSETS_DIR = BASE_DIR / "assets"
|
||||||
|
IMAGES_DIR = ASSETS_DIR / "images"
|
||||||
|
VIDEOS_DIR = ASSETS_DIR / "videos"
|
||||||
|
METADATA_FILE = BASE_DIR / "metadata.json"
|
||||||
|
STATIC_DIR = BASE_DIR / "static"
|
||||||
|
|
||||||
|
# Ensure directories exist
|
||||||
|
for directory in [IMAGES_DIR, VIDEOS_DIR, STATIC_DIR]:
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if not METADATA_FILE.exists():
|
||||||
|
METADATA_FILE.write_text("[]")
|
||||||
|
|
||||||
|
# Mount static files
|
||||||
|
app.mount("/web", StaticFiles(directory=str(STATIC_DIR), html=True), name="static")
|
||||||
|
|
||||||
|
def load_metadata() -> List[dict]:
|
||||||
|
try:
|
||||||
|
with open(METADATA_FILE, "r") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (json.JSONDecodeError, FileNotFoundError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def save_metadata(metadata: List[dict]):
|
||||||
|
with open(METADATA_FILE, "w") as f:
|
||||||
|
json.dump(metadata, f, indent=4)
|
||||||
|
|
||||||
|
def get_file_category(content_type: str) -> str:
|
||||||
|
if content_type.startswith("image/"):
|
||||||
|
return "images"
|
||||||
|
elif content_type.startswith("video/"):
|
||||||
|
return "videos"
|
||||||
|
return "others"
|
||||||
|
|
||||||
|
def sanitize_filename(filename: str) -> str:
|
||||||
|
# Basic sanitization: remove path traversal attempts and keep only alphanumeric/dots/underscores
|
||||||
|
return os.path.basename(filename).replace(" ", "_")
|
||||||
|
|
||||||
|
@app.post("/api/files/upload")
|
||||||
|
async def upload_file(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
description: Optional[str] = Form(None),
|
||||||
|
uploader: Optional[str] = Form(None)
|
||||||
|
):
|
||||||
|
category = get_file_category(file.content_type)
|
||||||
|
if category == "others":
|
||||||
|
raise HTTPException(status_code=400, detail="Unsupported file type. Only images and videos are allowed.")
|
||||||
|
|
||||||
|
filename = sanitize_filename(file.filename)
|
||||||
|
target_dir = IMAGES_DIR if category == "images" else VIDEOS_DIR
|
||||||
|
file_path = target_dir / filename
|
||||||
|
|
||||||
|
# Handle duplicate filenames by appending timestamp
|
||||||
|
if file_path.exists():
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||||
|
name, ext = os.path.splitext(filename)
|
||||||
|
filename = f"{name}_{timestamp}{ext}"
|
||||||
|
file_path = target_dir / filename
|
||||||
|
|
||||||
|
try:
|
||||||
|
with file_path.open("wb") as buffer:
|
||||||
|
shutil.copyfileobj(file.file, buffer)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Could not save file: {str(e)}")
|
||||||
|
|
||||||
|
metadata_entry = {
|
||||||
|
"filename": filename,
|
||||||
|
"type": file.content_type,
|
||||||
|
"upload_date": datetime.now().isoformat(),
|
||||||
|
"file_path": str(file_path.relative_to(BASE_DIR)),
|
||||||
|
"description": description,
|
||||||
|
"uploader": uploader
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata = load_metadata()
|
||||||
|
metadata.append(metadata_entry)
|
||||||
|
save_metadata(metadata)
|
||||||
|
|
||||||
|
return metadata_entry
|
||||||
|
|
||||||
|
@app.post("/api/files/youtube")
|
||||||
|
async def download_youtube(
|
||||||
|
url: str = Form(...),
|
||||||
|
description: Optional[str] = Form(None),
|
||||||
|
uploader: Optional[str] = Form(None)
|
||||||
|
):
|
||||||
|
ydl_opts = {
|
||||||
|
'format': 'bestvideo+bestaudio/best',
|
||||||
|
'outtmpl': str(VIDEOS_DIR / '%(title)s.%(ext)s'),
|
||||||
|
'quiet': True,
|
||||||
|
'no_warnings': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
info = ydl.extract_info(url, download=True)
|
||||||
|
file_path_str = ydl.prepare_filename(info)
|
||||||
|
file_path = Path(file_path_str)
|
||||||
|
filename = file_path.name
|
||||||
|
|
||||||
|
metadata_entry = {
|
||||||
|
"filename": filename,
|
||||||
|
"type": "video/mp4", # Simplified, yt-dlp might download different formats
|
||||||
|
"upload_date": datetime.now().isoformat(),
|
||||||
|
"file_path": str(file_path.relative_to(BASE_DIR)),
|
||||||
|
"description": description or info.get('title'),
|
||||||
|
"uploader": uploader or info.get('uploader')
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata = load_metadata()
|
||||||
|
metadata.append(metadata_entry)
|
||||||
|
save_metadata(metadata)
|
||||||
|
|
||||||
|
return metadata_entry
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"YouTube download failed: {str(e)}")
|
||||||
|
|
||||||
|
@app.get("/api/files")
|
||||||
|
async def list_files():
|
||||||
|
return load_metadata()
|
||||||
|
|
||||||
|
@app.get("/api/files/download")
|
||||||
|
async def download_file(filename: str, stream: bool = False):
|
||||||
|
metadata = load_metadata()
|
||||||
|
entry = next((item for item in metadata if item["filename"] == filename), None)
|
||||||
|
|
||||||
|
if not entry:
|
||||||
|
raise HTTPException(status_code=404, detail="File not found in metadata")
|
||||||
|
|
||||||
|
file_path = BASE_DIR / entry["file_path"]
|
||||||
|
if not file_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="File not found on disk")
|
||||||
|
|
||||||
|
# If stream is True, we use 'inline' so the browser can play it
|
||||||
|
disposition = "inline" if stream else "attachment"
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
path=file_path,
|
||||||
|
filename=filename if not stream else None,
|
||||||
|
media_type=entry["type"],
|
||||||
|
content_disposition_type=disposition
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.delete("/api/files")
|
||||||
|
async def delete_file(filename: str):
|
||||||
|
metadata = load_metadata()
|
||||||
|
entry_index = next((i for i, item in enumerate(metadata) if item["filename"] == filename), None)
|
||||||
|
|
||||||
|
if entry_index is None:
|
||||||
|
raise HTTPException(status_code=404, detail="File not found in metadata")
|
||||||
|
|
||||||
|
entry = metadata[entry_index]
|
||||||
|
file_path = BASE_DIR / entry["file_path"]
|
||||||
|
|
||||||
|
# Delete from filesystem
|
||||||
|
try:
|
||||||
|
if file_path.exists():
|
||||||
|
file_path.unlink()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Could not delete file from disk: {str(e)}")
|
||||||
|
|
||||||
|
# Remove from metadata
|
||||||
|
metadata.pop(entry_index)
|
||||||
|
save_metadata(metadata)
|
||||||
|
|
||||||
|
return {"message": f"File {filename} deleted successfully"}
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||||
26
metadata.json
Normal file
26
metadata.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"filename": "dummy.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"upload_date": "2026-02-19T09:45:18.881161",
|
||||||
|
"file_path": "assets/images/dummy.png",
|
||||||
|
"description": "Test image",
|
||||||
|
"uploader": "Claude"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "dummy.mp4",
|
||||||
|
"type": "video/mp4",
|
||||||
|
"upload_date": "2026-02-19T09:45:57.924247",
|
||||||
|
"file_path": "assets/videos/dummy.mp4",
|
||||||
|
"description": "Test video",
|
||||||
|
"uploader": "Claude"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Elegant Jazz \uff5c Sophisticated Melodies in a Luxury Hotel Lobby #1.webm",
|
||||||
|
"type": "video/mp4",
|
||||||
|
"upload_date": "2026-02-19T10:41:57.544173",
|
||||||
|
"file_path": "assets/videos/Elegant Jazz \uff5c Sophisticated Melodies in a Luxury Hotel Lobby #1.webm",
|
||||||
|
"description": "Elegant Jazz | Sophisticated Melodies in a Luxury Hotel Lobby #1",
|
||||||
|
"uploader": "Blue in Green"
|
||||||
|
}
|
||||||
|
]
|
||||||
301
static/index.html
Normal file
301
static/index.html
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Modern File Manager</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-color: #f0f2f5;
|
||||||
|
--primary-pastel: #a2d2ff;
|
||||||
|
--secondary-pastel: #ffafcc;
|
||||||
|
--accent-pastel: #bde0fe;
|
||||||
|
--success-pastel: #cdb4db;
|
||||||
|
--text-color: #4a4a4a;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--shadow: 0 8px 30px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; font-family: 'Poppins', sans-serif; }
|
||||||
|
body { background-color: var(--bg-color); color: var(--text-color); margin: 0; padding: 40px 20px; }
|
||||||
|
.container { max-width: 1000px; margin: 0 auto; }
|
||||||
|
|
||||||
|
h1 { text-align: center; color: #6a6a6a; font-weight: 600; margin-bottom: 40px; }
|
||||||
|
h3 { font-weight: 600; margin-top: 0; color: #888; }
|
||||||
|
|
||||||
|
/* Card Style */
|
||||||
|
.card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 30px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
margin-bottom: 30px;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Controls */
|
||||||
|
.form-group { margin-bottom: 15px; }
|
||||||
|
label { display: block; margin-bottom: 8px; font-size: 0.9rem; font-weight: 600; color: #999; }
|
||||||
|
input[type="text"], input[type="url"], input[type="file"] {
|
||||||
|
width: 100%; padding: 12px 15px; border: 2px solid #f1f1f1; border-radius: 12px;
|
||||||
|
outline: none; transition: border-color 0.3s;
|
||||||
|
}
|
||||||
|
input:focus { border-color: var(--primary-pastel); }
|
||||||
|
|
||||||
|
.btn-group { display: flex; gap: 10px; margin-top: 20px; }
|
||||||
|
button {
|
||||||
|
flex: 1; padding: 12px; border: none; border-radius: 12px; cursor: pointer;
|
||||||
|
font-weight: 600; transition: all 0.3s; color: white;
|
||||||
|
}
|
||||||
|
.btn-upload { background-color: var(--primary-pastel); color: #555; }
|
||||||
|
.btn-upload:hover { background-color: #89c2f7; transform: translateY(-2px); }
|
||||||
|
.btn-yt { background-color: var(--secondary-pastel); color: #555; }
|
||||||
|
.btn-yt:hover { background-color: #ff99bb; transform: translateY(-2px); }
|
||||||
|
button:disabled { background-color: #ccc; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* Video Player Area */
|
||||||
|
.video-player-container {
|
||||||
|
display: none; background: #000; border-radius: 20px; overflow: hidden;
|
||||||
|
margin-bottom: 30px; position: relative; box-shadow: 0 20px 50px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
video { width: 100%; display: block; }
|
||||||
|
.close-player {
|
||||||
|
position: absolute; top: 15px; right: 15px; background: rgba(255,255,255,0.3);
|
||||||
|
border: none; color: white; padding: 5px 15px; border-radius: 20px; cursor: pointer; backdrop-filter: blur(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid for Files */
|
||||||
|
.file-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 20px;
|
||||||
|
}
|
||||||
|
.file-card {
|
||||||
|
background: white; border-radius: 18px; padding: 20px; box-shadow: var(--shadow);
|
||||||
|
display: flex; flex-direction: column; justify-content: space-between; border: 1px solid #f9f9f9;
|
||||||
|
}
|
||||||
|
.file-info h4 { margin: 0 0 10px 0; font-size: 1.1rem; word-break: break-all; color: #555; }
|
||||||
|
.file-info p { margin: 5px 0; font-size: 0.85rem; color: #aaa; }
|
||||||
|
.file-tag {
|
||||||
|
display: inline-block; padding: 4px 10px; border-radius: 8px; font-size: 0.75rem;
|
||||||
|
margin-bottom: 15px; background: var(--accent-pastel); color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-actions { display: flex; gap: 10px; margin-top: 15px; }
|
||||||
|
.action-link {
|
||||||
|
text-decoration: none; font-size: 0.85rem; font-weight: 600;
|
||||||
|
padding: 8px 15px; border-radius: 10px; flex: 1; text-align: center;
|
||||||
|
}
|
||||||
|
.link-dl { background: #f0f0f0; color: #777; }
|
||||||
|
.link-play { background: var(--success-pastel); color: #555; }
|
||||||
|
.link-delete { background: #ffcfcf; color: #d65a5a; }
|
||||||
|
.link-delete:hover { background: #ffbaba; }
|
||||||
|
|
||||||
|
/* Loading Animation */
|
||||||
|
.loader {
|
||||||
|
border: 4px solid #f3f3f3; border-top: 4px solid var(--primary-pastel);
|
||||||
|
border-radius: 50%; width: 20px; height: 20px; animation: spin 1s linear infinite;
|
||||||
|
display: inline-block; vertical-align: middle; margin-right: 10px;
|
||||||
|
}
|
||||||
|
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.btn-group { flex-direction: column; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>✨ File Manager</h1>
|
||||||
|
|
||||||
|
<!-- Video Player -->
|
||||||
|
<div id="playerContainer" class="video-player-container card">
|
||||||
|
<button class="close-player" onclick="closePlayer()">Close ×</button>
|
||||||
|
<video id="videoPlayer" controls></video>
|
||||||
|
<div style="padding: 15px; background: white;">
|
||||||
|
<h4 id="playingTitle" style="margin:0; color: #555;">Playing...</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; flex-wrap: wrap; gap: 20px;">
|
||||||
|
<!-- Upload Form -->
|
||||||
|
<div class="card" style="flex: 1; min-width: 300px;">
|
||||||
|
<h3>📁 Local Upload</h3>
|
||||||
|
<form id="uploadForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Select File</label>
|
||||||
|
<input type="file" id="fileInput" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Description</label>
|
||||||
|
<input type="text" id="description" placeholder="What is this?">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Uploader</label>
|
||||||
|
<input type="text" id="uploader" placeholder="Your name">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-upload" id="upBtn">Upload to Cloud</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- YouTube Form -->
|
||||||
|
<div class="card" style="flex: 1; min-width: 300px;">
|
||||||
|
<h3>📺 YouTube Link</h3>
|
||||||
|
<form id="youtubeForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Video URL</label>
|
||||||
|
<input type="url" id="youtubeUrl" placeholder="https://youtube.com/..." required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Uploader Name</label>
|
||||||
|
<input type="text" id="ytUploader" placeholder="Your name">
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<button type="submit" class="btn-yt" id="ytBtn">Download Video</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>📦 Library</h3>
|
||||||
|
<div class="file-grid" id="fileGrid">
|
||||||
|
<!-- Files will be injected here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const uploadForm = document.getElementById('uploadForm');
|
||||||
|
const youtubeForm = document.getElementById('youtubeForm');
|
||||||
|
const fileGrid = document.getElementById('fileGrid');
|
||||||
|
|
||||||
|
async function fetchFiles() {
|
||||||
|
const response = await fetch('/api/files');
|
||||||
|
const files = await response.json();
|
||||||
|
fileGrid.innerHTML = '';
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
fileGrid.innerHTML = '<p style="grid-column: 1/-1; text-align: center; color: #ccc; margin-top: 40px;">No files found yet. Start uploading!</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
files.reverse().forEach(file => {
|
||||||
|
const isVideo = file.type.startsWith('video/');
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'file-card';
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="file-info">
|
||||||
|
<span class="file-tag">${file.type.split('/')[1].toUpperCase()}</span>
|
||||||
|
<h4>${file.filename}</h4>
|
||||||
|
<p>👤 ${file.uploader || 'Anonymous'}</p>
|
||||||
|
<p>📅 ${new Date(file.upload_date).toLocaleDateString()}</p>
|
||||||
|
${file.description ? `<p style="color: #888; margin-top:10px; font-style: italic;">"${file.description}"</p>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="file-actions">
|
||||||
|
<a href="/api/files/download?filename=${encodeURIComponent(file.filename)}" class="action-link link-dl">Down</a>
|
||||||
|
${isVideo ? `<a href="#" onclick="playVideo('${encodeURIComponent(file.filename)}')" class="action-link link-play">Play</a>` : ''}
|
||||||
|
<a href="#" onclick="deleteFile('${encodeURIComponent(file.filename)}')" class="action-link link-delete">Del</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
fileGrid.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function playVideo(filename) {
|
||||||
|
const playerContainer = document.getElementById('playerContainer');
|
||||||
|
const videoPlayer = document.getElementById('videoPlayer');
|
||||||
|
const playingTitle = document.getElementById('playingTitle');
|
||||||
|
|
||||||
|
playingTitle.innerText = 'Now Playing: ' + decodeURIComponent(filename);
|
||||||
|
videoPlayer.src = `/api/files/download?filename=${filename}&stream=true`;
|
||||||
|
playerContainer.style.display = 'block';
|
||||||
|
videoPlayer.play();
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePlayer() {
|
||||||
|
const playerContainer = document.getElementById('playerContainer');
|
||||||
|
const videoPlayer = document.getElementById('videoPlayer');
|
||||||
|
videoPlayer.pause();
|
||||||
|
videoPlayer.src = '';
|
||||||
|
playerContainer.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFile(filename) {
|
||||||
|
if (!confirm(`Are you sure you want to delete "${decodeURIComponent(filename)}"?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/files?filename=${filename}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
fetchFiles();
|
||||||
|
} else {
|
||||||
|
const err = await response.json();
|
||||||
|
alert('Delete failed: ' + err.detail);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Connection error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Local Upload
|
||||||
|
uploadForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const upBtn = document.getElementById('upBtn');
|
||||||
|
const originalText = upBtn.innerText;
|
||||||
|
upBtn.innerHTML = '<span class="loader"></span> Uploading...';
|
||||||
|
upBtn.disabled = true;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', document.getElementById('fileInput').files[0]);
|
||||||
|
formData.append('description', document.getElementById('description').value);
|
||||||
|
formData.append('uploader', document.getElementById('uploader').value);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/files/upload', { method: 'POST', body: formData });
|
||||||
|
if (response.ok) {
|
||||||
|
uploadForm.reset();
|
||||||
|
fetchFiles();
|
||||||
|
} else {
|
||||||
|
const err = await response.json();
|
||||||
|
alert('Error: ' + err.detail);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Connection error');
|
||||||
|
} finally {
|
||||||
|
upBtn.innerText = originalText;
|
||||||
|
upBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle YouTube Download
|
||||||
|
youtubeForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const ytBtn = document.getElementById('ytBtn');
|
||||||
|
const originalText = ytBtn.innerText;
|
||||||
|
ytBtn.innerHTML = '<span class="loader"></span> Processing YouTube...';
|
||||||
|
ytBtn.disabled = true;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('url', document.getElementById('youtubeUrl').value);
|
||||||
|
formData.append('uploader', document.getElementById('ytUploader').value);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/files/youtube', { method: 'POST', body: formData });
|
||||||
|
if (response.ok) {
|
||||||
|
youtubeForm.reset();
|
||||||
|
fetchFiles();
|
||||||
|
} else {
|
||||||
|
const err = await response.json();
|
||||||
|
alert('YouTube Error: ' + err.detail);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Connection error');
|
||||||
|
} finally {
|
||||||
|
ytBtn.innerText = originalText;
|
||||||
|
ytBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchFiles();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user