This commit is contained in:
2025-07-11 11:56:59 +02:00
parent 61984de1af
commit af882eece0
12 changed files with 409 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/data/
*.pyc

234
app.py Normal file
View File

@ -0,0 +1,234 @@
import os
import re
import requests
import urllib.parse
from bs4 import BeautifulSoup
from flask import Flask, request, redirect, render_template, send_from_directory, abort, url_for
from pathlib import Path
from datetime import datetime
from snowflake import SnowflakeGenerator
app = Flask(__name__)
snowflake = SnowflakeGenerator(1, epoch=int(datetime(2025, 1, 1, 0, 0, 0).timestamp() * 1000))
DATA_ROOT = "data"
ITEMS_ROOT = f"{DATA_ROOT}/items"
MEDIA_ROOT = f"{DATA_ROOT}/items"
EXTENSIONS = {
"images": ("jpg", "jpeg", "png", "gif", "webp", "avif"),
"videos": ("mp4", "mov", "mpeg", "ogv", "webm", "mkv"),
}
@app.route("/")
def index():
return render_template("index.html", media=walk_items())
@app.route("/media/<path:filename>")
def serve_media(filename):
return send_from_directory(MEDIA_ROOT, filename)
@app.route("/item/<path:filename>")
def view_item(filename):
# full_path = os.path.join(MEDIA_ROOT, filename)
# if not os.path.exists(full_path):
# abort(404)
item = load_item(filename)
if not item:
abort(404)
return render_template("item.html", filename=filename, item=item)
@app.route("/search")
def search():
query = request.args.get("query", "").lower()
results = {}
for folder, items in walk_items().items():
results[folder] = []
for item in items:
image = item["id"]
meta = load_sider_metadata(image) or {}
if any([query in text.lower() for text in [image, *meta.values()]]):
results[folder].append(image)
return render_template("search.html", media=results, query=query)
@app.route("/add", methods=["GET", "POST"])
def add_item():
item = {}
if request.method == "GET":
iid = request.args.get("item")
if iid:
item = load_item(iid)
elif request.method == "POST":
iid = request.form.get("id") or generate_iid()
# title = request.form.get("title")
# description = request.form.get("description")
# if (url := request.form.get("url")):
# download_item(url)
# else:
# with open(os.path.join(MEDIA_ROOT, f"{iid[1]}.item"), "w") as f:
# f.write(write_metadata({
# "description": description
# }))
# return redirect(url_for("index"))
filename = store_item(iid, {
"link": request.form.get("link"),
"title": request.form.get("title"),
"description": request.form.get("description"),
}, request.files['file'])
return redirect(url_for("view_item", filename=filename))
return render_template("add.html", item=item)
@app.route("/api/preview")
def preview():
return fetch_url_data(request.args.get("url"))
@app.errorhandler(404)
def error_404(e):
return render_template("404.html"), 404
def walk_items():
results = {}
for root, dirs, files in os.walk(MEDIA_ROOT):
rel_path = os.path.relpath(root, MEDIA_ROOT).replace(os.sep, "/")
if rel_path == ".":
rel_path = ""
results[rel_path] = []
for file in files:
filename = os.path.join(rel_path, file).replace(os.sep, "/")
if file.lower().endswith(".item"):
with open(os.path.join(MEDIA_ROOT, rel_path, file), "r") as f:
data = read_metadata(f.read())
data["id"] = filename
results[rel_path].append(data)
elif file.lower().endswith(tuple([f".{ext}" for ext in EXTENSIONS["images"]])):
# results[rel_path].append(os.path.join(rel_path, file).replace(os.sep, "/"))
image = os.path.join(rel_path, file).replace(os.sep, "/")
data = load_sider_metadata(image) or {}
data["image"] = image
data["id"] = filename
results[rel_path].append(data)
return results
def load_item(iid):
data = None
filepath = os.path.join(MEDIA_ROOT, iid)
if os.path.exists(filepath):
if iid.lower().endswith(".item"):
with open(filepath, "r") as f:
data = read_metadata(f.read())
else:
data = load_sider_metadata(iid) or {}
data["image"] = iid
if data:
data["id"] = iid
return data
def load_sider_metadata(filename):
filepath = os.path.join(MEDIA_ROOT, f"{strip_ext(filename)}.meta")
if os.path.exists(filepath):
with open(filepath, "r") as f:
return read_metadata(f.read())
def read_metadata(text:str):
data = {}
for elem in BeautifulSoup(re.sub(r'<(\w+)>(.*?)</>', r'<\1>\2</\1>', text), "html.parser").find_all():
data[elem.name] = elem.text.strip()
return data
def write_metadata(data:dict):
text = ""
for key in data:
if (value := data[key]):
text += f'<{key}>{value}</>\n'
return text
def fetch_url_data(url:str):
try:
response = requests.get(url, timeout=5)
soup = BeautifulSoup(response.text, "html.parser")
description = None
desc_tag = soup.find("meta", attrs={"name": "description"}) or \
soup.find("meta", attrs={"property": "og:description"})
if desc_tag and "content" in desc_tag.attrs:
description = desc_tag["content"]
image = None
img_tag = soup.find("meta", attrs={"property": "og:image"}) or \
soup.find("meta", attrs={"name": "twitter:image"})
if img_tag and "content" in img_tag.attrs:
image = img_tag["content"]
return {
"title": soup_or_default(soup, "meta", {"property": "og:title"}, "content", (soup.title.string if soup.title else None)),
"description": description,
"image": image,
"link": soup_or_default(soup, "link", {"rel": "canonical"}, "href", url),
}
except Exception as e:
# print("Metadata fetch failed:", e)
return {}
def download_item(url:str):
data = fetch_url_data(url)
url = urllib.parse.urlparse(data["link"])
slug = (url.path or "index").split("/")[-1]
domain = url.netloc
Path(os.path.join(MEDIA_ROOT, domain)).mkdir(parents=True, exist_ok=True)
path = os.path.join(MEDIA_ROOT, domain, slug)
with open(f"{path}.meta", "w") as f:
f.write(write_metadata(data))
def store_item(iid, data, file):
item = load_item(iid)
iid = split_iid(strip_ext(iid))
filepath = os.path.join(MEDIA_ROOT, *iid)
Path(os.path.join(MEDIA_ROOT, iid[0])).mkdir(parents=True, exist_ok=True)
if file:
with open(f"{filepath}.meta", "w") as f:
f.write(write_metadata(data))
# with open(f"{filepath}.{ext}", "wb") as f:
# f.write(write_metadata(data))
ext = file.content_type.split("/")[1]
file.save(f'{filepath}.{ext}')
return "/".join(iid) + f".{ext}"
else:
with open(f"{filepath}.item", "w") as f:
f.write(write_metadata(data))
return "/".join(iid) + ".item"
# return "/".join(iid)
def prop_or_default(items:dict, prop:str, default):
return (items[prop] if (items and prop in items) else None) or default
def soup_or_default(soup:BeautifulSoup, tag:str, attrs:dict, prop:str, default):
return prop_or_default(soup.find(tag, attrs=attrs), prop, default)
def generate_iid():
date = datetime.now()
return f"{date.year}/{date.month}/{next(snowflake)}"
# return [f"{date.year}/{date.month}", next(snowflake)]
def split_iid(iid:str):
iid = iid.split("/")
return ["/".join(iid[:-1]), iid[-1]]
def strip_ext(filename:str):
return os.path.splitext(filename)[0]
if __name__ == "__main__":
app.run(debug=True)

3
requirements.txt Normal file
View File

@ -0,0 +1,3 @@
flask
requests
snowflake-id

6
templates/404.html Normal file
View File

@ -0,0 +1,6 @@
{% extends "base.html" %}
{% block title %}404 Not Found{% endblock %}
{% block content %}
<h1>Not Found</h1>
<p>The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.</p>
{% endblock %}

42
templates/add.html Normal file
View File

@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block title %}🆕 Add item{% endblock %}
{% block content %}
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="id" value="{{ item['id'] }}" />
<div class="uk-margin">
<div uk-form-custom>
<input type="file" name="file" aria-label="Custom controls" />
<button class="uk-button uk-button-default" type="button" tabindex="-1">Select file</button>
</div>
</div>
<div class="uk-margin">
<input class="uk-input" type="text" name="url" placeholder="URL..." value="{{ item.url }}" />
</div>
<div class="uk-margin">
<input class="uk-input" type="text" name="title" placeholder="Title" value="{{ item.title }}" />
</div>
<div class="uk-margin">
<textarea class="uk-textarea" name="description" placeholder="Write anything...">{{ item.description }}</textarea>
</div>
<div class="uk-margin">
<button class="uk-input" type="submit">{{ ("Edit " + item.id) if item.id else "Add" }}</button>
</div>
</form>
<script>
var url = document.querySelector('form input[name="url"]');
['change', 'input', 'paste'].forEach(handler => {
url.addEventListener(handler, () => {
fetch('http://localhost:5000/api/preview?url=' + url.value)
.then(res => res.json())
.then(data => {
for (var key in data) {
var field = document.querySelector(`form [name="${key}"]`);
if (field) {
field.value = data[key];
}
}
})
})
});
</script>
{% endblock %}

54
templates/base.html Normal file
View File

@ -0,0 +1,54 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}My Media Gallery{% endblock %}</title>
<link rel="canonical" href="{% block canonical %}{% endblock %}" />
<!-- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css" /> -->
<!-- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css" /> -->
<!-- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script> -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.23.11/dist/css/uikit.min.css" />
<script src="https://cdn.jsdelivr.net/npm/uikit@3.23.11/dist/js/uikit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/uikit@3.23.11/dist/js/uikit-icons.min.js"></script>
<!-- <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/milligram/1.4.1/milligram.css"> -->
<style>
/* body { font-family: sans-serif; margin: 1em; }
.container { margin: 1em; }
h2 { margin-top: 40px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; }
.grid img { width: 100%; height: auto; border-radius: 8px; }
.search { display: flex; }
.search input { flex: 1; height: 2em; } */
/* img { max-width: 31%; } */
</style>
</head>
<body>
<nav class="uk-navbar-container" uk-navbar>
<div class="uk-navbar-left">
<a class="uk-button uk-button-primary" href="{{ url_for('index') }}">🏠 Home</a>
</div>
<div class="uk-navbar-center">
<form class="uk-search uk-search-default" action="{{ url_for('search') }}">
<input class="uk-search-input" type="search" name="query" placeholder="Search or Add..." value="{{ query }}" required>
<button class="uk-search-icon-flip" uk-search-icon></button>
</form>
</div>
<div class="uk-navbar-right">
<a class="uk-button uk-button-secondary" href="{{ url_for('add_item') }}">🆕 Add</a>
</div>
</nav>
<div class="uk-container uk-margin">
{% block content %}{% endblock %}
</div>
</body>
</html>

0
templates/card.html Normal file
View File

6
templates/index.html Normal file
View File

@ -0,0 +1,6 @@
{% extends "base.html" %}
{% block title %}📁 Media Index{% endblock %}
<!-- {% block canonical %}{{ url_for('index') }}{% endblock %} -->
{% block content %}
{% include "results.html" %}
{% endblock %}

View File

@ -0,0 +1 @@
{{ item.title or (item.image or item.id).split("/")[-1] }}

30
templates/item.html Normal file
View File

@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block title %}📌 Viewing {{ filename }}{% endblock %}
{% block content %}
{% raw %}
<!-- <style>
img { max-width: 49%; border-radius: 10px; }
.meta { display: inline-block; max-width: 49%; vertical-align: top; padding: 1em; box-sizing: border-box; }
</style> -->
<!-- <img src="{{ url_for('serve_media', filename=filename) }}" />
<div class="meta">
<p>{{ meta.description }}</p>
<p><strong>File:</strong> {{ filename }}</p>
<p><strong>Link:</strong> <a href="{{ url_for('view_item', filename=filename) }}">{{ request.url }}</a></p>
</div> -->
{% endraw %}
<div class="uk-flex uk-flex-wrap">
<div class="uk-width-1-1 uk-width-1-2@s uk-padding-small">
{% if item.image %}
<img src="{{ url_for('serve_media', filename=filename) }}" />
{% endif %}
</div>
<div class="uk-width-1-1 uk-width-1-2@s uk-padding-small">
<h3>{% include "item-title.html" %}</h3>
<span>{{ item.created }}</span>
<p>{{ item.description }}</p>
<a class="uk-button uk-button-default" href="{{ url_for('add_item') }}?item={{ item.id }}">Edit</a>
<a class="uk-button uk-button-danger" href="{{ url_for('add_item') }}?item={{ item.id }}">Remove</a>
</div>
</div>
{% endblock %}

21
templates/results.html Normal file
View File

@ -0,0 +1,21 @@
<div uk-grid="masonry: pack" class="uk-child-width-1-2 uk-child-width-1-3@s uk-child-width-1-4@m uk-child-width-1-5@l uk-child-width-1-6@xl">
{% for folder, items in media.items() %}
<!--<h2>{{ folder }}</h2>
<div uk-grid="masonry: pack" class="uk-child-width-1-2 uk-child-width-1-3@s uk-child-width-1-4@m uk-child-width-1-5@l uk-child-width-1-6@xl">-->
{% for item in items %}
<div margin="uk-grid-margin-small">
<a class="uk-link-text" href="{{ url_for('view_item', filename=item.id) }}">
{% if item.image %}
<img src="{{ url_for('serve_media', filename=item.image) }}" />
{% elif item.description %}
<div class="uk-background-cover uk-height-small uk-flex uk-flex-center uk-flex-middle" style="background-image: url('https://getuikit.com/docs/images/dark.jpg');">
<p class="uk-text-stroke uk-text-primary">{{ item.description }}</p>
</div>
{% endif %}
<span class="uk-text-break">{% include "item-title.html" %}</span>
</a>
</div>
{% endfor %}
<!--</div>-->
{% endfor %}
</div>

10
templates/search.html Normal file
View File

@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block title %}Search Results for "{{ query }}"{% endblock %}
{% block content %}
{% if media_data %}
<p>🔍 Results for "{{ query }}"</p>
{% include "results.html" %}
{% else %}
<p>No matches found for "<strong>{{ query }}</strong>".</p>
{% endif %}
{% endblock %}