revamped media scanner using custom built rust program, saving and getting progress capabilties, and more
This commit is contained in:
7
.vscode/settings.json
vendored
Normal file
7
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"python.languageServer": "Pylance",
|
||||||
|
"python.analysis.diagnosticSeverityOverrides": {
|
||||||
|
"reportMissingModuleSource": "none",
|
||||||
|
"reportShadowedImports": "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
37
client.py
Normal file
37
client.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import requests
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
|
API_BASE_URL = "http://localhost:8000"
|
||||||
|
|
||||||
|
def list_movies():
|
||||||
|
url = f"{API_BASE_URL}/movies"
|
||||||
|
try:
|
||||||
|
response = requests.get(url)
|
||||||
|
response.raise_for_status()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error fetching movies: {e}")
|
||||||
|
return []
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def main():
|
||||||
|
movies = list_movies()
|
||||||
|
if not movies:
|
||||||
|
print("No movies found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("Available Movies:")
|
||||||
|
for movie in movies:
|
||||||
|
print(f"{movie['id']}: {movie.get('title', 'Unknown Title')}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
movie_id = int(input("Enter movie ID to stream: "))
|
||||||
|
except ValueError:
|
||||||
|
print("Invalid input. Please enter a numeric movie ID.")
|
||||||
|
return
|
||||||
|
|
||||||
|
stream_url = f"{API_BASE_URL}/stream/{movie_id}"
|
||||||
|
print(f"Opening stream for movie ID {movie_id}...")
|
||||||
|
webbrowser.open(stream_url)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
630
main.py
Normal file
630
main.py
Normal file
@@ -0,0 +1,630 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
import jwt
|
||||||
|
import json
|
||||||
|
from fastapi import FastAPI, HTTPException, Request, Depends, Header, WebSocket, WebSocketDisconnect, Query
|
||||||
|
from fastapi.responses import StreamingResponse, FileResponse
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
import media_scanner # Import the Rust module
|
||||||
|
from rapidfuzz import fuzz
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
MOVIES_DIR = r"Z:\plexmediaserver\movies" # Directory containing movie files
|
||||||
|
TV_SHOWS_DIR = r"Z:\plexmediaserver\tv" # Directory containing TV shows and episodes
|
||||||
|
DB_PATH = "movies.db" # SQLite database file
|
||||||
|
OMDB_API_KEY = "8275d9b8" # Get from http://www.omdbapi.com/
|
||||||
|
|
||||||
|
# Authentication settings
|
||||||
|
SECRET_KEY = "yoursecretkey" # Use a secure secret in production!
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_SECONDS = 600
|
||||||
|
|
||||||
|
# Set up a password context for hashing
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
# Initialize FastAPI
|
||||||
|
app = FastAPI(title='Movie Library API')
|
||||||
|
|
||||||
|
# Global dictionary to store sync sessions keyed by session_id
|
||||||
|
sync_sessions = {}
|
||||||
|
|
||||||
|
@app.websocket("/ws/sync/{session_id}")
|
||||||
|
async def websocket_sync(session_id: str, websocket: WebSocket, media_id: str = Query(...), media_type: str = Query(...)):
|
||||||
|
await websocket.accept()
|
||||||
|
if session_id not in sync_sessions:
|
||||||
|
sync_sessions[session_id] = []
|
||||||
|
sync_sessions[session_id].append((websocket, media_id, media_type))
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await websocket.receive_text()
|
||||||
|
message_payload = {"media_id": media_id, "media_type": media_type, "data": data}
|
||||||
|
broadcast = json.dumps(message_payload)
|
||||||
|
for connection, _, _ in sync_sessions[session_id]:
|
||||||
|
if connection != websocket:
|
||||||
|
await connection.send_text(broadcast)
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
sync_sessions[session_id] = [
|
||||||
|
(conn, m_id, m_type) for conn, m_id, m_type in sync_sessions[session_id] if conn != websocket
|
||||||
|
]
|
||||||
|
if not sync_sessions[session_id]:
|
||||||
|
del sync_sessions[session_id]
|
||||||
|
|
||||||
|
def create_access_token(data: dict, expires_delta: int = ACCESS_TOKEN_EXPIRE_SECONDS):
|
||||||
|
to_encode = data.copy()
|
||||||
|
expire = int(time.time()) + expires_delta
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
token = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
return token
|
||||||
|
|
||||||
|
def verify_token(token: str):
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
return payload # In a more complete system, you may return a user object.
|
||||||
|
except jwt.PyJWTError:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||||
|
|
||||||
|
# Dependency to extract and verify token from the header
|
||||||
|
def get_current_user(authorization: str = Header(...)):
|
||||||
|
if not authorization.startswith("Bearer "):
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid authentication header")
|
||||||
|
token = authorization[len("Bearer "):]
|
||||||
|
return verify_token(token)
|
||||||
|
|
||||||
|
# Database helper functions
|
||||||
|
def init_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS movies (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
filepath TEXT UNIQUE,
|
||||||
|
title TEXT,
|
||||||
|
year TEXT,
|
||||||
|
rated TEXT,
|
||||||
|
released TEXT,
|
||||||
|
runtime TEXT,
|
||||||
|
genre TEXT,
|
||||||
|
director TEXT,
|
||||||
|
writer TEXT,
|
||||||
|
actors TEXT,
|
||||||
|
plot TEXT,
|
||||||
|
language TEXT,
|
||||||
|
country TEXT,
|
||||||
|
awards TEXT,
|
||||||
|
poster TEXT,
|
||||||
|
imdb_rating TEXT,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
c.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS episodes (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
filepath TEXT UNIQUE,
|
||||||
|
tv_show_id INTEGER,
|
||||||
|
season INTEGER,
|
||||||
|
episode INTEGER,
|
||||||
|
title TEXT,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(tv_show_id) REFERENCES tv_shows(id)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
c.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS tv_shows (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT UNIQUE,
|
||||||
|
rating TEXT,
|
||||||
|
summary TEXT,
|
||||||
|
genres TEXT,
|
||||||
|
poster TEXT,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
c.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS accounts (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
username TEXT UNIQUE,
|
||||||
|
hashed_password TEXT
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
# New table for tracking watch progress
|
||||||
|
c.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS watch_progress (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
username TEXT,
|
||||||
|
media_type TEXT, -- 'movie' or 'episode'
|
||||||
|
media_id INTEGER,
|
||||||
|
last_position INTEGER,
|
||||||
|
tv_show_id INTEGER, -- added tv_show_id column
|
||||||
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(username, media_type, media_id)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def movie_exists(rel_path):
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute('SELECT 1 FROM movies WHERE filepath = ?', (rel_path,))
|
||||||
|
exists = c.fetchone() is not None
|
||||||
|
conn.close()
|
||||||
|
return exists
|
||||||
|
|
||||||
|
def add_movie_to_db(movie):
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute('''
|
||||||
|
INSERT OR IGNORE INTO movies (
|
||||||
|
filepath, title, year, rated, released, runtime, genre,
|
||||||
|
director, writer, actors, plot, language, country,
|
||||||
|
awards, poster, imdb_rating, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
''', (
|
||||||
|
movie['filepath'], movie.get('Title'), movie.get('Year'), movie.get('Rated'),
|
||||||
|
movie.get('Released'), movie.get('Runtime'), movie.get('Genre'),
|
||||||
|
movie.get('Director'), movie.get('Writer'), movie.get('Actors'),
|
||||||
|
movie.get('Plot'), movie.get('Language'), movie.get('Country'),
|
||||||
|
movie.get('Awards'), movie.get('Poster'), movie.get('imdbRating')
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def fetch_movie_details(title, year=None):
|
||||||
|
params = {'t': title, 'apikey': OMDB_API_KEY}
|
||||||
|
if year:
|
||||||
|
params['y'] = year
|
||||||
|
response = requests.get('http://www.omdbapi.com/', params=params)
|
||||||
|
data = response.json()
|
||||||
|
if data.get('Response') == 'True':
|
||||||
|
return data
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Movie '{title}' not found.")
|
||||||
|
|
||||||
|
def tv_show_exists(show_name):
|
||||||
|
normalized_name = show_name.strip().lower()
|
||||||
|
alt_name = ("the " + normalized_name).strip()
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute('SELECT 1 FROM tv_shows WHERE lower(name) = ? OR lower(name) = ?', (normalized_name, alt_name))
|
||||||
|
exists = c.fetchone() is not None
|
||||||
|
conn.close()
|
||||||
|
return exists
|
||||||
|
|
||||||
|
def fetch_tv_show_details(show_name):
|
||||||
|
response = requests.get("http://api.tvmaze.com/singlesearch/shows", params={'q': show_name})
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise ValueError(f"TV show '{show_name}' not found via TV API.")
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def add_tv_show_to_db(details):
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
genres = ", ".join(details.get('genres', []))
|
||||||
|
rating = details.get('rating', {}).get('average', 'N/A')
|
||||||
|
summary = details.get('summary', '')
|
||||||
|
image = details.get('image', {}).get('medium', '') if details.get('image') else ''
|
||||||
|
c.execute('''
|
||||||
|
INSERT OR IGNORE INTO tv_shows (
|
||||||
|
name, rating, summary, genres, poster, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
''', (details.get('name'), rating, summary, genres, image))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def scan_and_populate():
|
||||||
|
init_db()
|
||||||
|
processed_shows = set() # Keep track of processed TV shows
|
||||||
|
try:
|
||||||
|
# Use Rust for scanning movies
|
||||||
|
print("Scanning movies...")
|
||||||
|
movie_files = media_scanner.scan_movies(MOVIES_DIR)
|
||||||
|
print(f"Found movie files: {movie_files}")
|
||||||
|
for full_path in movie_files:
|
||||||
|
parent = os.path.basename(os.path.dirname(full_path))
|
||||||
|
match = re.match(r"(.+?)\s*\((\d{4})\)$", parent)
|
||||||
|
if match:
|
||||||
|
title = match.group(1).strip()
|
||||||
|
year = match.group(2)
|
||||||
|
else:
|
||||||
|
title = os.path.splitext(os.path.basename(full_path))[0]
|
||||||
|
year = None
|
||||||
|
rel_path = os.path.relpath(full_path, MOVIES_DIR)
|
||||||
|
if movie_exists(rel_path):
|
||||||
|
print(f"Movie already exists: {title} ({year or 'n/a'})")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
details = fetch_movie_details(title, year)
|
||||||
|
details['filepath'] = rel_path
|
||||||
|
add_movie_to_db(details)
|
||||||
|
print(f"Added {title} ({year or 'n/a'}) to database.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Skipping {title}: {e}")
|
||||||
|
|
||||||
|
# Updated logic for scanning TV shows with fuzzy matching
|
||||||
|
print("Scanning TV shows...")
|
||||||
|
tv_show_files = media_scanner.scan_tv_shows(TV_SHOWS_DIR)
|
||||||
|
print(f"Found TV show files: {tv_show_files}")
|
||||||
|
for full_path in tv_show_files:
|
||||||
|
# Extract TV show name from the folder structure: "Show Name\Season X\filename"
|
||||||
|
show_name = os.path.basename(os.path.dirname(os.path.dirname(full_path)))
|
||||||
|
# Extract season number from the season folder ("Season X")
|
||||||
|
season_dir = os.path.basename(os.path.dirname(full_path))
|
||||||
|
season_match = re.search(r"Season\s*(\d+)", season_dir, re.IGNORECASE)
|
||||||
|
if not season_match:
|
||||||
|
print(f"Skipping {full_path}: Season number not found in directory '{season_dir}'")
|
||||||
|
continue
|
||||||
|
season = int(season_match.group(1))
|
||||||
|
# Extract episode number solely from the SxxEyy pattern in the filename
|
||||||
|
basename = os.path.basename(full_path)
|
||||||
|
ep_match = re.search(r"(?i)S(\d{2})E(\d{2})", basename)
|
||||||
|
if not ep_match:
|
||||||
|
print(f"Skipping {full_path}: SxxEyy pattern not found in filename")
|
||||||
|
continue
|
||||||
|
episode = int(ep_match.group(2))
|
||||||
|
# Check and add TV show details if not processed yet
|
||||||
|
if show_name not in processed_shows:
|
||||||
|
if not tv_show_exists(show_name):
|
||||||
|
try:
|
||||||
|
tv_details = fetch_tv_show_details(show_name)
|
||||||
|
add_tv_show_to_db(tv_details)
|
||||||
|
print(f"Added TV show details: {show_name}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Skipping TV show details for {show_name}: {e}")
|
||||||
|
else:
|
||||||
|
print(f"TV show already exists: {show_name}")
|
||||||
|
processed_shows.add(show_name)
|
||||||
|
rel_path = os.path.relpath(full_path, TV_SHOWS_DIR)
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
# Try exact match first
|
||||||
|
c.execute('SELECT id, name FROM tv_shows WHERE lower(name) = ?', (show_name.lower(),))
|
||||||
|
row = c.fetchone()
|
||||||
|
if row:
|
||||||
|
tv_show_id = row[0]
|
||||||
|
else:
|
||||||
|
# If not found, use fuzzy matching with a threshold (e.g. 80)
|
||||||
|
c.execute('SELECT id, name FROM tv_shows')
|
||||||
|
tv_show_row = None
|
||||||
|
for db_row in c.fetchall():
|
||||||
|
db_id, db_name = db_row
|
||||||
|
similarity = fuzz.ratio(db_name.lower(), show_name.lower())
|
||||||
|
if similarity > 80:
|
||||||
|
tv_show_row = (db_id, db_name)
|
||||||
|
break
|
||||||
|
if tv_show_row:
|
||||||
|
tv_show_id = tv_show_row[0]
|
||||||
|
print(f"Fuzzy matched '{show_name}' to '{tv_show_row[1]}' with score {similarity}")
|
||||||
|
else:
|
||||||
|
print(f"TV show id not found for {show_name}")
|
||||||
|
conn.close()
|
||||||
|
continue
|
||||||
|
# Use the complete filename (without extension) as the episode title
|
||||||
|
title = os.path.splitext(basename)[0]
|
||||||
|
c.execute('''
|
||||||
|
INSERT OR IGNORE INTO episodes (filepath, tv_show_id, season, episode, title, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
''', (rel_path, tv_show_id, season, episode, title))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(f"Added episode: {show_name} Season {season} Episode {episode}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error during scanning: {e}")
|
||||||
|
|
||||||
|
def range_streamer(file_path: str, range_header: str = None, chunk_size: int = 1024*1024):
|
||||||
|
file_size = os.path.getsize(file_path)
|
||||||
|
if range_header is None:
|
||||||
|
def iterfile():
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
while (chunk := f.read(chunk_size)):
|
||||||
|
yield chunk
|
||||||
|
return StreamingResponse(iterfile(), media_type="video/mp4")
|
||||||
|
m = re.search(r'bytes=(\d+)-(\d*)', range_header)
|
||||||
|
if m:
|
||||||
|
start = int(m.group(1))
|
||||||
|
end = m.group(2)
|
||||||
|
if end:
|
||||||
|
end = int(end)
|
||||||
|
else:
|
||||||
|
end = file_size - 1
|
||||||
|
else:
|
||||||
|
start = 0
|
||||||
|
end = file_size - 1
|
||||||
|
content_length = (end - start) + 1
|
||||||
|
headers = {
|
||||||
|
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
"Content-Length": str(content_length)
|
||||||
|
}
|
||||||
|
def iter_range():
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
f.seek(start)
|
||||||
|
remaining = content_length
|
||||||
|
while remaining > 0:
|
||||||
|
chunk = f.read(min(chunk_size, remaining))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
remaining -= len(chunk)
|
||||||
|
yield chunk
|
||||||
|
return StreamingResponse(iter_range(), status_code=206, headers=headers, media_type="video/mp4")
|
||||||
|
|
||||||
|
@app.get('/movies')
|
||||||
|
def list_movies(current_user: dict = Depends(get_current_user)):
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute('SELECT * FROM movies')
|
||||||
|
cols = [desc[0] for desc in c.description]
|
||||||
|
movies = [dict(zip(cols, row)) for row in c.fetchall()]
|
||||||
|
conn.close()
|
||||||
|
return movies
|
||||||
|
|
||||||
|
@app.get('/movies/{movie_id}')
|
||||||
|
def get_movie(movie_id: int, current_user: dict = Depends(get_current_user)):
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute('SELECT * FROM movies WHERE id = ?', (movie_id,))
|
||||||
|
row = c.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if row:
|
||||||
|
cols = [desc[0] for desc in c.description]
|
||||||
|
return dict(zip(cols, row))
|
||||||
|
raise HTTPException(status_code=404, detail='Movie not found')
|
||||||
|
|
||||||
|
@app.get('/stream/{movie_id}')
|
||||||
|
# def stream_movie(movie_id: int, request: Request, current_user: dict = Depends(get_current_user)):
|
||||||
|
def stream_movie(movie_id: int, request: Request):
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute('SELECT filepath FROM movies WHERE id = ?', (movie_id,))
|
||||||
|
row = c.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail='Movie not found')
|
||||||
|
file_path = os.path.join(MOVIES_DIR, row[0])
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
raise HTTPException(status_code=404, detail='File not found')
|
||||||
|
range_header = request.headers.get('range')
|
||||||
|
return range_streamer(file_path, range_header)
|
||||||
|
|
||||||
|
@app.get('/episodes')
|
||||||
|
def list_episodes(current_user: dict = Depends(get_current_user)):
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute('SELECT * FROM episodes')
|
||||||
|
cols = [desc[0] for desc in c.description]
|
||||||
|
episodes = [dict(zip(cols, row)) for row in c.fetchall()]
|
||||||
|
conn.close()
|
||||||
|
return episodes
|
||||||
|
|
||||||
|
@app.get('/shows')
|
||||||
|
def list_shows(current_user: dict = Depends(get_current_user)):
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute('SELECT * FROM tv_shows')
|
||||||
|
cols = [desc[0] for desc in c.description]
|
||||||
|
shows = [dict(zip(cols, row)) for row in c.fetchall()]
|
||||||
|
conn.close()
|
||||||
|
return shows
|
||||||
|
|
||||||
|
@app.get('/shows/{show_id}')
|
||||||
|
def get_tv_show(show_id: int, current_user: dict = Depends(get_current_user)):
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute('SELECT * FROM tv_shows WHERE id = ?', (show_id,))
|
||||||
|
row = c.fetchone()
|
||||||
|
cols = [desc[0] for desc in c.description] if row else []
|
||||||
|
conn.close()
|
||||||
|
if row:
|
||||||
|
return dict(zip(cols, row))
|
||||||
|
raise HTTPException(status_code=404, detail="TV show not found")
|
||||||
|
|
||||||
|
@app.get('/shows/{show_id}/seasons')
|
||||||
|
def list_seasons(show_id: int, current_user: dict = Depends(get_current_user)):
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute('SELECT DISTINCT season FROM episodes WHERE tv_show_id = ?', (show_id,))
|
||||||
|
seasons = sorted([row[0] for row in c.fetchall()])
|
||||||
|
conn.close()
|
||||||
|
if not seasons:
|
||||||
|
raise HTTPException(status_code=404, detail='Seasons not found for this TV show')
|
||||||
|
return seasons
|
||||||
|
|
||||||
|
@app.get('/shows/{show_id}/seasons/{season}/episodes')
|
||||||
|
def list_episodes_for_season(show_id: int, season: int, current_user: dict = Depends(get_current_user)):
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute('''
|
||||||
|
SELECT id, filepath, episode, title
|
||||||
|
FROM episodes
|
||||||
|
WHERE tv_show_id = ? AND season = ?
|
||||||
|
ORDER BY episode
|
||||||
|
''', (show_id, season))
|
||||||
|
episodes = [dict(zip(['id', 'filepath', 'episode', 'title'], row)) for row in c.fetchall()]
|
||||||
|
conn.close()
|
||||||
|
if not episodes:
|
||||||
|
raise HTTPException(status_code=404, detail='No episodes found for this season')
|
||||||
|
return episodes
|
||||||
|
|
||||||
|
@app.get('/stream_episode/{episode_id}')
|
||||||
|
# def stream_episode(episode_id: int, request: Request, current_user: dict = Depends(get_current_user)):
|
||||||
|
def stream_episode(episode_id: int, request: Request):
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute('SELECT filepath FROM episodes WHERE id = ?', (episode_id,))
|
||||||
|
row = c.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail='Episode not found')
|
||||||
|
file_path = os.path.join(TV_SHOWS_DIR, row[0])
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
raise HTTPException(status_code=404, detail='File not found')
|
||||||
|
range_header = request.headers.get('range')
|
||||||
|
return range_streamer(file_path, range_header)
|
||||||
|
|
||||||
|
@app.post('/register')
|
||||||
|
def register_account(user: dict):
|
||||||
|
username = user.get("username")
|
||||||
|
password = user.get("password")
|
||||||
|
if not username or not password:
|
||||||
|
raise HTTPException(status_code=400, detail="Username and password required")
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("SELECT id FROM accounts WHERE username = ?", (username,))
|
||||||
|
if c.fetchone():
|
||||||
|
conn.close()
|
||||||
|
raise HTTPException(status_code=400, detail="Username already exists")
|
||||||
|
hashed_password = pwd_context.hash(password)
|
||||||
|
c.execute("INSERT INTO accounts (username, hashed_password) VALUES (?, ?)", (username, hashed_password))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return {"message": "Account registered successfully"}
|
||||||
|
|
||||||
|
@app.post('/login')
|
||||||
|
def login_account(user: dict):
|
||||||
|
username = user.get("username")
|
||||||
|
password = user.get("password")
|
||||||
|
if not username or not password:
|
||||||
|
raise HTTPException(status_code=400, detail="Username and password required")
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("SELECT hashed_password FROM accounts WHERE username = ?", (username,))
|
||||||
|
row = c.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row or not pwd_context.verify(password, row[0]):
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||||
|
token = create_access_token({"sub": username})
|
||||||
|
return {"access_token": token, "token_type": "bearer"}
|
||||||
|
|
||||||
|
@app.post('/scan')
|
||||||
|
def scan_new_files(current_user: dict = Depends(get_current_user)):
|
||||||
|
try:
|
||||||
|
scan_and_populate()
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
@app.get("/sessions/{session_id}")
|
||||||
|
async def get_session_details(session_id: str):
|
||||||
|
if session_id in sync_sessions:
|
||||||
|
# Return the media_id and media_type of the first connection in the session
|
||||||
|
_, media_id, media_type = sync_sessions[session_id][0]
|
||||||
|
return {"session_id": session_id, "media_id": media_id, "media_type": media_type}
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
|
||||||
|
# New endpoint to save watch progress (record timestamp when user stops watching)
|
||||||
|
@app.post('/save_progress/{media_type}/{media_id}')
|
||||||
|
def save_progress(media_type: str, media_id: int, progress: dict, current_user: dict = Depends(get_current_user)):
|
||||||
|
last_position = progress.get("last_position")
|
||||||
|
if last_position is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing last_position in payload")
|
||||||
|
|
||||||
|
tv_show_id = None
|
||||||
|
if media_type.lower() == "episode":
|
||||||
|
# Lookup the associated tv_show_id for the episode.
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("SELECT tv_show_id FROM episodes WHERE id = ?", (media_id,))
|
||||||
|
row = c.fetchone()
|
||||||
|
if row:
|
||||||
|
tv_show_id = row[0]
|
||||||
|
print(f"Found tv_show_id {tv_show_id} for episode {media_id}")
|
||||||
|
else:
|
||||||
|
print(f"No tv show found for episode {media_id}")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
if media_type.lower() == "episode" and tv_show_id is not None:
|
||||||
|
# For episodes, we assume a unique SQL index exists on (username, tv_show_id)
|
||||||
|
c.execute('''
|
||||||
|
INSERT INTO watch_progress (username, media_type, media_id, last_position, tv_show_id, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(username, tv_show_id)
|
||||||
|
DO UPDATE SET media_id = excluded.media_id, last_position = excluded.last_position, updated_at = CURRENT_TIMESTAMP
|
||||||
|
''', (current_user.get("sub"), media_type, media_id, last_position, tv_show_id))
|
||||||
|
else:
|
||||||
|
# For movies (or episodes without a tv_show_id), fall back to unique(username, media_type, media_id)
|
||||||
|
c.execute('''
|
||||||
|
INSERT INTO watch_progress (username, media_type, media_id, last_position, tv_show_id, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(username, media_type, media_id)
|
||||||
|
DO UPDATE SET last_position = excluded.last_position, tv_show_id = excluded.tv_show_id, updated_at = CURRENT_TIMESTAMP
|
||||||
|
''', (current_user.get("sub"), media_type, media_id, last_position, tv_show_id))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print(f"Progress saved for {media_type} ID {media_id} at position {last_position}")
|
||||||
|
response = {"message": "Progress saved", "media_type": media_type, "media_id": media_id, "last_position": last_position}
|
||||||
|
if tv_show_id:
|
||||||
|
response["tv_show_id"] = tv_show_id
|
||||||
|
return response
|
||||||
|
|
||||||
|
# New endpoint to get saved watch progress so the user can resume the video
|
||||||
|
@app.get('/get_progress/{media_type}/{media_id}')
|
||||||
|
def get_progress(media_type: str, media_id: int, current_user: dict = Depends(get_current_user)):
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute('''
|
||||||
|
SELECT last_position FROM watch_progress
|
||||||
|
WHERE username = ? AND media_type = ? AND media_id = ?
|
||||||
|
''', (current_user.get("sub"), media_type, media_id))
|
||||||
|
row = c.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if row:
|
||||||
|
return {"media_type": media_type, "media_id": media_id, "last_position": row[0]}
|
||||||
|
return {"media_type": media_type, "media_id": media_id, "last_position": 0}
|
||||||
|
|
||||||
|
@app.get('/in_progress')
|
||||||
|
def list_in_progress(current_user: dict = Depends(get_current_user)):
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute('''
|
||||||
|
SELECT media_type, media_id, last_position
|
||||||
|
FROM watch_progress
|
||||||
|
WHERE username = ?
|
||||||
|
''', (current_user.get("sub"),))
|
||||||
|
rows = c.fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
movies = []
|
||||||
|
episodes = []
|
||||||
|
for media_type, media_id, last_position in rows:
|
||||||
|
item = {"media_id": media_id, "last_position": last_position}
|
||||||
|
if media_type.lower() == "movie":
|
||||||
|
movies.append(item)
|
||||||
|
elif media_type.lower() == "episode":
|
||||||
|
episodes.append(item)
|
||||||
|
|
||||||
|
print({"movies": movies, "episodes": episodes})
|
||||||
|
|
||||||
|
return {"movies": movies, "episodes": episodes}
|
||||||
|
|
||||||
|
@app.get('/episodes/{episode_id}/show')
|
||||||
|
def get_show_for_episode(episode_id: int, current_user: dict = Depends(get_current_user)):
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
# Retrieve the tv_show_id for the given episode
|
||||||
|
c.execute('SELECT tv_show_id FROM episodes WHERE id = ?', (episode_id,))
|
||||||
|
result = c.fetchone()
|
||||||
|
if not result:
|
||||||
|
conn.close()
|
||||||
|
raise HTTPException(status_code=404, detail="Episode not found")
|
||||||
|
tv_show_id = result[0]
|
||||||
|
# Retrieve the TV show details using the tv_show_id
|
||||||
|
c.execute('SELECT * FROM tv_shows WHERE id = ?', (tv_show_id,))
|
||||||
|
row = c.fetchone()
|
||||||
|
if not row:
|
||||||
|
conn.close()
|
||||||
|
raise HTTPException(status_code=404, detail="TV show not found")
|
||||||
|
cols = [desc[0] for desc in c.description]
|
||||||
|
show = dict(zip(cols, row))
|
||||||
|
conn.close()
|
||||||
|
return show
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
scan_and_populate()
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host='0.0.0.0', port=8000)
|
||||||
Reference in New Issue
Block a user