mediahive 0.4.1__tar.gz
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.
- mediahive-0.4.1/.gitignore +21 -0
- mediahive-0.4.1/PKG-INFO +72 -0
- mediahive-0.4.1/README.md +47 -0
- mediahive-0.4.1/mediahive/__init__.py +1 -0
- mediahive-0.4.1/mediahive/__main__.py +87 -0
- mediahive-0.4.1/mediahive/access_logging.py +253 -0
- mediahive-0.4.1/mediahive/config.py +63 -0
- mediahive-0.4.1/mediahive/frontend-build/assets/bluray-DQPJ6Ch-.webp +0 -0
- mediahive-0.4.1/mediahive/frontend-build/assets/cast-placeholder-female-DV4xMBuR.svg +37 -0
- mediahive-0.4.1/mediahive/frontend-build/assets/dolby-vision-atmos-CTfuH_q4.webp +0 -0
- mediahive-0.4.1/mediahive/frontend-build/assets/dvd-CsCD8SQT.webp +0 -0
- mediahive-0.4.1/mediahive/frontend-build/assets/hdr10plus-logo-De6l5oJY.png +0 -0
- mediahive-0.4.1/mediahive/frontend-build/assets/index-DOO8b-Bp.css +1 -0
- mediahive-0.4.1/mediahive/frontend-build/assets/index-DuSqEb6o.js +29 -0
- mediahive-0.4.1/mediahive/frontend-build/assets/mediahive-B5YUc37N.webp +0 -0
- mediahive-0.4.1/mediahive/frontend-build/assets/search-worker-BgynRKq7.js +1 -0
- mediahive-0.4.1/mediahive/frontend-build/assets/service-amazon-Cy-ob7iY.webp +0 -0
- mediahive-0.4.1/mediahive/frontend-build/assets/service-apple-tv-m1hMKL8A.webp +0 -0
- mediahive-0.4.1/mediahive/frontend-build/assets/service-disney-ztYWlSpJ.svg +747 -0
- mediahive-0.4.1/mediahive/frontend-build/assets/service-hbo-max-DR_yPnyK.webp +0 -0
- mediahive-0.4.1/mediahive/frontend-build/assets/service-hulu-Dcj42L77.webp +0 -0
- mediahive-0.4.1/mediahive/frontend-build/assets/service-itunes-CBBxr6j3.png +0 -0
- mediahive-0.4.1/mediahive/frontend-build/assets/service-netflix-iIEe6juJ.webp +0 -0
- mediahive-0.4.1/mediahive/frontend-build/index.html +18 -0
- mediahive-0.4.1/mediahive/frontend-build/mediahive-32.webp +0 -0
- mediahive-0.4.1/mediahive/frontend-build/mediahive.webp +0 -0
- mediahive-0.4.1/mediahive/hivescan/__init__.py +20 -0
- mediahive-0.4.1/mediahive/hivescan/__main__.py +78 -0
- mediahive-0.4.1/mediahive/hivescan/images.py +152 -0
- mediahive-0.4.1/mediahive/hivescan/indexer.py +944 -0
- mediahive-0.4.1/mediahive/hivescan/models.py +53 -0
- mediahive-0.4.1/mediahive/hivescan/parsing.py +91 -0
- mediahive-0.4.1/mediahive/hivescan/scanignore.py +147 -0
- mediahive-0.4.1/mediahive/hivescan/scanner.py +754 -0
- mediahive-0.4.1/mediahive/hivescan/scanning.py +459 -0
- mediahive-0.4.1/mediahive/hivescan/showreel.py +1033 -0
- mediahive-0.4.1/mediahive/hivescan/tmdb_client.py +598 -0
- mediahive-0.4.1/mediahive/hivescan/utils.py +294 -0
- mediahive-0.4.1/mediahive/index_store.py +477 -0
- mediahive-0.4.1/mediahive/models/__init__.py +1 -0
- mediahive-0.4.1/mediahive/models/data.py +115 -0
- mediahive-0.4.1/mediahive/models/events.py +39 -0
- mediahive-0.4.1/mediahive/models/protocol.py +157 -0
- mediahive-0.4.1/mediahive/models/tmdb.py +85 -0
- mediahive-0.4.1/mediahive/players.py +363 -0
- mediahive-0.4.1/mediahive/root_registry.py +333 -0
- mediahive-0.4.1/mediahive/server.py +1287 -0
- mediahive-0.4.1/mediahive/volume_control.py +475 -0
- mediahive-0.4.1/mediahive/winmain.py +1041 -0
- mediahive-0.4.1/pyproject.toml +121 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[oc]
|
|
4
|
+
build/
|
|
5
|
+
dist/
|
|
6
|
+
wheels/
|
|
7
|
+
*.egg-info
|
|
8
|
+
|
|
9
|
+
# Node / Frontend
|
|
10
|
+
node_modules/
|
|
11
|
+
frontend/dist/
|
|
12
|
+
mediahive/frontend-build/
|
|
13
|
+
|
|
14
|
+
# Lockfiles (uv.lock, package-lock.json, deno.lock, bun.lockb, etc.)
|
|
15
|
+
*.lock
|
|
16
|
+
*.lockb
|
|
17
|
+
package-lock.json
|
|
18
|
+
|
|
19
|
+
# Dotfiles
|
|
20
|
+
.*
|
|
21
|
+
!.gitignore
|
mediahive-0.4.1/PKG-INFO
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mediahive
|
|
3
|
+
Version: 0.4.1
|
|
4
|
+
Summary: MediaHive - Media scanning, indexing, and Netflix-style streaming
|
|
5
|
+
Project-URL: Repository, https://git.zi.fi/LeoVasanko/mediahive
|
|
6
|
+
Requires-Python: >=3.14
|
|
7
|
+
Requires-Dist: aiofiles>=25.1.0
|
|
8
|
+
Requires-Dist: aiopathlib>=0.6.0
|
|
9
|
+
Requires-Dist: bencodepy>=0.9.5
|
|
10
|
+
Requires-Dist: fastapi-vue>=0.5.2
|
|
11
|
+
Requires-Dist: fastapi[standard]>=0.128.0
|
|
12
|
+
Requires-Dist: httpx[http2]>=0.28.1
|
|
13
|
+
Requires-Dist: msgspec>=0.19
|
|
14
|
+
Requires-Dist: parse-torrent-title>=2.8.1
|
|
15
|
+
Requires-Dist: tomli-w>=1.2.0
|
|
16
|
+
Requires-Dist: uvicorn[standard]>=0.40.0
|
|
17
|
+
Provides-Extra: gui
|
|
18
|
+
Requires-Dist: pyinstaller>=6.0; extra == 'gui'
|
|
19
|
+
Requires-Dist: pyqt5>=5.15.11; (platform_system == 'Darwin') and extra == 'gui'
|
|
20
|
+
Requires-Dist: pythonnet>=3.1.0rc0; (platform_system == 'Windows' and python_version >= '3.14') and extra == 'gui'
|
|
21
|
+
Requires-Dist: pywebview>=6.2.1; (platform_system != 'Darwin') and extra == 'gui'
|
|
22
|
+
Requires-Dist: pywebview[qt5]>=6.2.1; (platform_system == 'Darwin') and extra == 'gui'
|
|
23
|
+
Requires-Dist: qtpy>=2.4.1; (platform_system == 'Darwin') and extra == 'gui'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+

|
|
27
|
+
|
|
28
|
+
# MediaHive
|
|
29
|
+
|
|
30
|
+
Netflix style browsing of your local media archive. Supports keyboard, mouse and gamepad navigation. Uses your favorite movie player.
|
|
31
|
+
|
|
32
|
+
**[Windows and Mac portable ZIP downloads](https://git.zi.fi/LeoVasanko/mediahive/releases)**
|
|
33
|
+
|
|
34
|
+
## What It Does
|
|
35
|
+
|
|
36
|
+
- Scans your chosen media folder for all movies and series that can be found
|
|
37
|
+
- Produces preview video clips and downloads metadata
|
|
38
|
+
- Search on cast and character names, not just titles
|
|
39
|
+
- Hand off playback to your preferred system player
|
|
40
|
+
- Implement gamepad controls for MPC-BE on Windows (where needed)
|
|
41
|
+
|
|
42
|
+
Extract the ZIP in some place and run MediaHive.exe to start the app. Currently we have no installer, but you can pin to start/taskbar for easier access. On the first startup the app asks for your media folder, that can later be changed by clicking in-app folder icon.
|
|
43
|
+
|
|
44
|
+
Note that `.mediahive` folder is created in your media folder to hold all the metadata and preview clips, avoiding the lengthy processing that you will see on initial startup.
|
|
45
|
+
|
|
46
|
+
## Controls
|
|
47
|
+
|
|
48
|
+
MediaHive is designed to work with a mouse, keyboard, or gamepad.
|
|
49
|
+
|
|
50
|
+
| Input | Controls |
|
|
51
|
+
| --- | --- |
|
|
52
|
+
| Mouse | Click posters, rows, search, play, and folder actions directly. |
|
|
53
|
+
| Keyboard | Arrow keys move focus, `Enter` activates the focused item, `Escape` goes back, and `/` jumps to search. |
|
|
54
|
+
| Gamepad | D-pad or left stick moves focus, `A` selects or plays, and `B` goes back. `RB`/`LB` browses adjacent items, and the Search bar has an OSD keyboard. Player controls during playback. |
|
|
55
|
+
|
|
56
|
+
## Recommended Players
|
|
57
|
+
|
|
58
|
+
- Windows: [MPC-BE](https://github.com/Aleksoid1978/MPC-BE/releases)
|
|
59
|
+
- macOS: [IINA](https://iina.io/)
|
|
60
|
+
- Linux: SMPlayer
|
|
61
|
+
|
|
62
|
+
MediaHive opens files with the OS default player, but one specific player may be configured via settings. You are of course free to use any player instead.
|
|
63
|
+
|
|
64
|
+
- `A` toggles play and pause.
|
|
65
|
+
- `B` closes the player.
|
|
66
|
+
- `Y` toggles mute.
|
|
67
|
+
- D-pad up and down change volume.
|
|
68
|
+
- D-pad left and right seek during playback, or step frames while paused.
|
|
69
|
+
|
|
70
|
+
## Background
|
|
71
|
+
|
|
72
|
+
This project started as a personal project that I have used for browsing my warez for some time now. It is still in early development, but I have just now made it public for a wider audience.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
3
|
+
# MediaHive
|
|
4
|
+
|
|
5
|
+
Netflix style browsing of your local media archive. Supports keyboard, mouse and gamepad navigation. Uses your favorite movie player.
|
|
6
|
+
|
|
7
|
+
**[Windows and Mac portable ZIP downloads](https://git.zi.fi/LeoVasanko/mediahive/releases)**
|
|
8
|
+
|
|
9
|
+
## What It Does
|
|
10
|
+
|
|
11
|
+
- Scans your chosen media folder for all movies and series that can be found
|
|
12
|
+
- Produces preview video clips and downloads metadata
|
|
13
|
+
- Search on cast and character names, not just titles
|
|
14
|
+
- Hand off playback to your preferred system player
|
|
15
|
+
- Implement gamepad controls for MPC-BE on Windows (where needed)
|
|
16
|
+
|
|
17
|
+
Extract the ZIP in some place and run MediaHive.exe to start the app. Currently we have no installer, but you can pin to start/taskbar for easier access. On the first startup the app asks for your media folder, that can later be changed by clicking in-app folder icon.
|
|
18
|
+
|
|
19
|
+
Note that `.mediahive` folder is created in your media folder to hold all the metadata and preview clips, avoiding the lengthy processing that you will see on initial startup.
|
|
20
|
+
|
|
21
|
+
## Controls
|
|
22
|
+
|
|
23
|
+
MediaHive is designed to work with a mouse, keyboard, or gamepad.
|
|
24
|
+
|
|
25
|
+
| Input | Controls |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| Mouse | Click posters, rows, search, play, and folder actions directly. |
|
|
28
|
+
| Keyboard | Arrow keys move focus, `Enter` activates the focused item, `Escape` goes back, and `/` jumps to search. |
|
|
29
|
+
| Gamepad | D-pad or left stick moves focus, `A` selects or plays, and `B` goes back. `RB`/`LB` browses adjacent items, and the Search bar has an OSD keyboard. Player controls during playback. |
|
|
30
|
+
|
|
31
|
+
## Recommended Players
|
|
32
|
+
|
|
33
|
+
- Windows: [MPC-BE](https://github.com/Aleksoid1978/MPC-BE/releases)
|
|
34
|
+
- macOS: [IINA](https://iina.io/)
|
|
35
|
+
- Linux: SMPlayer
|
|
36
|
+
|
|
37
|
+
MediaHive opens files with the OS default player, but one specific player may be configured via settings. You are of course free to use any player instead.
|
|
38
|
+
|
|
39
|
+
- `A` toggles play and pause.
|
|
40
|
+
- `B` closes the player.
|
|
41
|
+
- `Y` toggles mute.
|
|
42
|
+
- D-pad up and down change volume.
|
|
43
|
+
- D-pad left and right seek during playback, or step frames while paused.
|
|
44
|
+
|
|
45
|
+
## Background
|
|
46
|
+
|
|
47
|
+
This project started as a personal project that I have used for browsing my warez for some time now. It is still in early development, but I have just now made it public for a wider audience.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""MediaHive - Media Browser Server."""
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""MediaHive CLI entrypoint."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from fastapi_vue import server
|
|
11
|
+
|
|
12
|
+
DEFAULT_PORT = 8420
|
|
13
|
+
DEVMODE = os.getenv("MEDIAHIVE_DEV") == "1"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _configure_windows_event_loop_policy() -> None:
|
|
17
|
+
"""Ensure Windows uses Proactor loop so asyncio subprocess APIs are available."""
|
|
18
|
+
if sys.platform != "win32":
|
|
19
|
+
return
|
|
20
|
+
import warnings
|
|
21
|
+
|
|
22
|
+
with warnings.catch_warnings():
|
|
23
|
+
warnings.simplefilter("ignore", DeprecationWarning)
|
|
24
|
+
policy_cls = getattr(asyncio, "WindowsProactorEventLoopPolicy", None)
|
|
25
|
+
if policy_cls is None:
|
|
26
|
+
return
|
|
27
|
+
asyncio.set_event_loop_policy(policy_cls())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _derive_name(path: str) -> str:
|
|
31
|
+
"""Derive a root name from a path."""
|
|
32
|
+
p = Path(path)
|
|
33
|
+
return p.name or p.anchor.strip("/\\").lower() or "media"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main() -> None:
|
|
37
|
+
_configure_windows_event_loop_policy()
|
|
38
|
+
|
|
39
|
+
parser = argparse.ArgumentParser(
|
|
40
|
+
description="MediaHive - Media scanning, indexing, and streaming"
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"media_folders",
|
|
44
|
+
nargs="*",
|
|
45
|
+
metavar="MEDIA_FOLDER",
|
|
46
|
+
help=(
|
|
47
|
+
"One or more media folders to index "
|
|
48
|
+
"(default: none — configure via UI or API)"
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"-l",
|
|
53
|
+
"--listen",
|
|
54
|
+
action="append",
|
|
55
|
+
help=(f"Endpoint (default: localhost:{DEFAULT_PORT})."),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
args = parser.parse_args()
|
|
59
|
+
|
|
60
|
+
if args.media_folders:
|
|
61
|
+
roots: dict[str, str] = {}
|
|
62
|
+
for path in args.media_folders:
|
|
63
|
+
# Defer filesystem validation to the server so startup is never
|
|
64
|
+
# blocked by macOS permission dialogs or missing paths.
|
|
65
|
+
p = Path(path).expanduser()
|
|
66
|
+
name = _derive_name(p.as_posix())
|
|
67
|
+
# Resolve collisions
|
|
68
|
+
base_name = name
|
|
69
|
+
suffix = 2
|
|
70
|
+
while name in roots:
|
|
71
|
+
name = f"{base_name}{suffix}"
|
|
72
|
+
suffix += 1
|
|
73
|
+
roots[name] = p.as_posix()
|
|
74
|
+
os.environ["MEDIAHIVE_ROOTS"] = json.dumps(roots)
|
|
75
|
+
|
|
76
|
+
dev = {"reload": True, "reload_dirs": ["mediahive"]}
|
|
77
|
+
server.run(
|
|
78
|
+
"mediahive.server:app",
|
|
79
|
+
listen=args.listen,
|
|
80
|
+
default_port=DEFAULT_PORT,
|
|
81
|
+
loop="none" if sys.platform == "win32" else "auto",
|
|
82
|
+
**(dev if DEVMODE and sys.platform != "win32" else {}),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
main()
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Custom access logging middleware for FastAPI/Uvicorn."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
from ipaddress import IPv6Address
|
|
7
|
+
|
|
8
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
9
|
+
from starlette.requests import Request
|
|
10
|
+
from starlette.responses import Response
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("mediahive.access")
|
|
13
|
+
|
|
14
|
+
_RESET = "\033[0m"
|
|
15
|
+
_STATUS_INFO = "\033[32m" # 1xx (green)
|
|
16
|
+
_STATUS_OK = "\033[1;92m" # 2xx (bright green)
|
|
17
|
+
_STATUS_REDIRECT = "\033[32m" # 3xx (green)
|
|
18
|
+
_STATUS_CLIENT_ERR = "\033[0;31m" # 4xx (red)
|
|
19
|
+
_STATUS_SERVER_ERR = "\033[1;91m" # 5xx (bold bright red)
|
|
20
|
+
_METHOD_READ = "\033[0;34m" # GET, HEAD, OPTIONS (blue)
|
|
21
|
+
_METHOD_WRITE = "\033[1;94m" # POST, PUT, DELETE, PATCH (bold bright blue)
|
|
22
|
+
_HOST = "\033[38;5;242m" # hostname (dark grey)
|
|
23
|
+
_PATH = "\033[38;5;250m" # path (white)
|
|
24
|
+
_TIMING = "\033[38;5;242m" # timing/devmode (dark grey)
|
|
25
|
+
_WS_OPEN = "\033[38;5;226m" # WebSocket connect (brightest yellow from 6x6x6 cube)
|
|
26
|
+
_WS_CLOSE = "\033[38;5;142m" # WebSocket disconnect (significantly dimmer yellow)
|
|
27
|
+
_WS_STATUS = "\033[38;5;242m" # WebSocket close status (dark grey)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def format_ipv6_network(ip: str) -> str:
|
|
31
|
+
"""Format IPv6 address to show only network part (first 64 bits).
|
|
32
|
+
|
|
33
|
+
Special addresses are returned as-is for clarity:
|
|
34
|
+
- ::1 (loopback)
|
|
35
|
+
- :: (unspecified)
|
|
36
|
+
- ::ffff:x.x.x.x (IPv4-mapped, returns just the IPv4 part)
|
|
37
|
+
- fe80:: (link-local, returned as-is since interface-specific)
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
# Strip brackets that some proxies add around IPv6
|
|
41
|
+
ip = ip.strip("[]")
|
|
42
|
+
# Strip zone ID (e.g., fe80::1%eth0)
|
|
43
|
+
if "%" in ip:
|
|
44
|
+
ip = ip.split("%")[0]
|
|
45
|
+
addr = IPv6Address(ip)
|
|
46
|
+
|
|
47
|
+
# Special cases - return as-is or with minimal processing
|
|
48
|
+
if addr.is_loopback: # ::1
|
|
49
|
+
return "::1"
|
|
50
|
+
if addr.is_unspecified: # ::
|
|
51
|
+
return "::"
|
|
52
|
+
if addr.ipv4_mapped: # ::ffff:x.x.x.x
|
|
53
|
+
return str(addr.ipv4_mapped)
|
|
54
|
+
if addr.is_link_local: # fe80::/10 - interface-specific, keep full
|
|
55
|
+
return str(addr)
|
|
56
|
+
|
|
57
|
+
# Regular addresses: truncate to /64 network prefix
|
|
58
|
+
network_int = int(addr) >> 64
|
|
59
|
+
# Format as IPv6 with trailing ::
|
|
60
|
+
# Split into 4 groups of 16 bits
|
|
61
|
+
groups = []
|
|
62
|
+
for _ in range(4):
|
|
63
|
+
groups.insert(0, format(network_int & 0xFFFF, "x"))
|
|
64
|
+
network_int >>= 16
|
|
65
|
+
# Compress consecutive zero groups
|
|
66
|
+
result = ":".join(groups) + "::"
|
|
67
|
+
# Simplify leading zeros in groups and compress, then strip trailing ::
|
|
68
|
+
return str(IPv6Address(result + "0")).removesuffix("::")
|
|
69
|
+
except Exception:
|
|
70
|
+
return ip
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def format_client_ip(ip: str) -> str:
|
|
74
|
+
"""Format client IP, compressing IPv6 to network part only."""
|
|
75
|
+
if not ip or ip == "-":
|
|
76
|
+
return "-"
|
|
77
|
+
# Strip brackets for detection (some proxies add them)
|
|
78
|
+
stripped = ip.strip("[]")
|
|
79
|
+
if ":" in stripped:
|
|
80
|
+
return format_ipv6_network(ip)
|
|
81
|
+
return ip
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def status_color(status: int) -> str:
|
|
85
|
+
"""Return color code based on HTTP status."""
|
|
86
|
+
if status < 200:
|
|
87
|
+
return _STATUS_INFO
|
|
88
|
+
if status < 300:
|
|
89
|
+
return _STATUS_OK
|
|
90
|
+
if status < 400:
|
|
91
|
+
return _STATUS_REDIRECT
|
|
92
|
+
if status < 500:
|
|
93
|
+
return _STATUS_CLIENT_ERR
|
|
94
|
+
return _STATUS_SERVER_ERR
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def method_color(method: str) -> str:
|
|
98
|
+
"""Return color code based on HTTP method."""
|
|
99
|
+
if method in ("GET", "HEAD", "OPTIONS"):
|
|
100
|
+
return _METHOD_READ
|
|
101
|
+
return _METHOD_WRITE
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def format_access_log(
|
|
105
|
+
client: str,
|
|
106
|
+
status: int,
|
|
107
|
+
method: str,
|
|
108
|
+
host: str,
|
|
109
|
+
path: str,
|
|
110
|
+
duration_ms: float,
|
|
111
|
+
extra: str = "",
|
|
112
|
+
) -> str:
|
|
113
|
+
"""Format access log line with colors and aligned fields."""
|
|
114
|
+
# Format components with fixed widths for alignment
|
|
115
|
+
ip = format_client_ip(client).ljust(19) # IPv6 network max 19 chars
|
|
116
|
+
timing = f"{duration_ms:.0f}ms"
|
|
117
|
+
method_padded = method.ljust(7) # Longest method is OPTIONS (7)
|
|
118
|
+
|
|
119
|
+
status_str = f"{status_color(status)}{status}{_RESET}"
|
|
120
|
+
timing_str = f"{_TIMING}{timing}{_RESET}"
|
|
121
|
+
method_str = f"{method_color(method)}{method_padded}{_RESET}"
|
|
122
|
+
host_str = f"{_HOST}{host}{_RESET}"
|
|
123
|
+
path_str = f"{_PATH}{path}{_RESET}"
|
|
124
|
+
|
|
125
|
+
# Format: "IP STATUS METHOD host path [extra] TIMING"
|
|
126
|
+
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
|
|
127
|
+
return (
|
|
128
|
+
f"{ip} {status_str} {method_str} {host_str}{path_str}{extra_str} {timing_str}"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# WebSocket connection counter (mod 100)
|
|
133
|
+
_ws_counter = 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _next_ws_id() -> int:
|
|
137
|
+
"""Get next WebSocket connection ID (0-99)."""
|
|
138
|
+
global _ws_counter
|
|
139
|
+
ws_id = _ws_counter
|
|
140
|
+
_ws_counter = (_ws_counter + 1) % 100
|
|
141
|
+
return ws_id
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def log_ws_open(ws) -> int:
|
|
145
|
+
"""Log WebSocket connection open. Returns connection ID for use in close."""
|
|
146
|
+
ws_id = _next_ws_id()
|
|
147
|
+
|
|
148
|
+
client = ws.client.host if ws.client else "-"
|
|
149
|
+
host = ws.headers.get("host", "-")
|
|
150
|
+
path = ws.url.path
|
|
151
|
+
origin = ws.headers.get("origin")
|
|
152
|
+
|
|
153
|
+
ip = format_client_ip(client).ljust(19)
|
|
154
|
+
# ID right-aligned like status codes (3 chars), emoji formatted like method
|
|
155
|
+
id_str = f"{_WS_OPEN}{str(ws_id).rjust(3)}{_RESET}"
|
|
156
|
+
# Emoji (2 display width) + 6 spaces = 8 display chars, but within color for alignment
|
|
157
|
+
emoji_str = f"{_METHOD_READ}🔌 {_RESET}"
|
|
158
|
+
|
|
159
|
+
# Determine if origin should be shown (omit when same as host)
|
|
160
|
+
# Origin header includes scheme (e.g., "https://example.com"), compare host part
|
|
161
|
+
origin_host = origin.split("://", 1)[-1] if origin else None
|
|
162
|
+
show_origin = origin_host and origin_host != host
|
|
163
|
+
|
|
164
|
+
host_str = f"{_HOST}{host}{_RESET}"
|
|
165
|
+
path_str = f"{_PATH}{path}{_RESET}"
|
|
166
|
+
origin_str = f" {_RESET}from {_HOST}{origin_host}{_RESET}" if show_origin else ""
|
|
167
|
+
|
|
168
|
+
logger.info(f"{ip} {id_str} {emoji_str}{host_str}{path_str}{origin_str}")
|
|
169
|
+
return ws_id
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# WebSocket close codes to human-readable status
|
|
173
|
+
WS_CLOSE_CODES = {
|
|
174
|
+
1000: "ok",
|
|
175
|
+
1001: "going away",
|
|
176
|
+
1002: "protocol error",
|
|
177
|
+
1003: "unsupported",
|
|
178
|
+
1005: "no status",
|
|
179
|
+
1006: "abnormal",
|
|
180
|
+
1007: "invalid data",
|
|
181
|
+
1008: "policy violation",
|
|
182
|
+
1009: "too large",
|
|
183
|
+
1010: "extension required",
|
|
184
|
+
1011: "server error",
|
|
185
|
+
1012: "restarting",
|
|
186
|
+
1013: "try again",
|
|
187
|
+
1014: "bad gateway",
|
|
188
|
+
1015: "tls error",
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
|
|
193
|
+
"""Log WebSocket connection close with duration and status."""
|
|
194
|
+
# ID right-aligned like status codes (3 chars), "closed" formatted like method
|
|
195
|
+
id_str = f"{_WS_CLOSE}{str(ws_id).rjust(3)}{_RESET}"
|
|
196
|
+
# Pad within the dim color to keep full width in color (8 display chars)
|
|
197
|
+
closed_str = f"{_TIMING}closed {_RESET}"
|
|
198
|
+
timing = f"{duration * 1000:.0f}ms"
|
|
199
|
+
|
|
200
|
+
# Convert close code to status text
|
|
201
|
+
if close_code is None:
|
|
202
|
+
code = "----"
|
|
203
|
+
status = "unknown"
|
|
204
|
+
else:
|
|
205
|
+
code = str(close_code)
|
|
206
|
+
status = WS_CLOSE_CODES.get(close_code, f"code {close_code}")
|
|
207
|
+
|
|
208
|
+
# Status code and text in normal color, not dim
|
|
209
|
+
status_str = f"{code} {status}"
|
|
210
|
+
timing_str = f"{_TIMING}{timing}{_RESET}"
|
|
211
|
+
|
|
212
|
+
logger.info(f"{' ' * 19} {id_str} {closed_str}{status_str} {timing_str}")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class AccessLogMiddleware(BaseHTTPMiddleware):
|
|
216
|
+
"""Middleware that logs HTTP requests with custom format."""
|
|
217
|
+
|
|
218
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
219
|
+
start = time.perf_counter()
|
|
220
|
+
response = await call_next(request)
|
|
221
|
+
duration_ms = (time.perf_counter() - start) * 1000
|
|
222
|
+
|
|
223
|
+
client = request.client.host if request.client else "-"
|
|
224
|
+
host = request.headers.get("host", "-")
|
|
225
|
+
method = request.method
|
|
226
|
+
path = request.url.path
|
|
227
|
+
if request.url.query:
|
|
228
|
+
path = f"{path}?{request.url.query}"
|
|
229
|
+
status = response.status_code
|
|
230
|
+
|
|
231
|
+
extra = getattr(request.state, "log_extra", "")
|
|
232
|
+
|
|
233
|
+
line = format_access_log(
|
|
234
|
+
client, status, method, host, path, duration_ms, extra=extra
|
|
235
|
+
)
|
|
236
|
+
logger.info(line)
|
|
237
|
+
|
|
238
|
+
return response
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def configure_access_logging():
|
|
242
|
+
"""Configure the access logger to output to stderr."""
|
|
243
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
244
|
+
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
245
|
+
logger.addHandler(handler)
|
|
246
|
+
logger.setLevel(logging.INFO)
|
|
247
|
+
logger.propagate = False
|
|
248
|
+
# Suppress uvicorn access logs to avoid duplicate request lines.
|
|
249
|
+
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
|
250
|
+
# Suppress uvicorn websocket "connection open/closed" messages.
|
|
251
|
+
logging.getLogger("uvicorn.error").setLevel(logging.WARNING)
|
|
252
|
+
# Suppress watchfiles "X changes detected" INFO messages (keep WARNING for reload notification)
|
|
253
|
+
logging.getLogger("watchfiles.main").setLevel(logging.WARNING)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
r"""Platform-appropriate config persistence for MediaHive.
|
|
2
|
+
|
|
3
|
+
Config file location:
|
|
4
|
+
Windows: %APPDATA%\mediahive\config.toml
|
|
5
|
+
macOS: ~/Library/Application Support/mediahive/config.toml
|
|
6
|
+
Linux: $XDG_CONFIG_HOME/mediahive/config.toml (~/.config/mediahive/config.toml)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import msgspec
|
|
14
|
+
import msgspec.toml
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Config(msgspec.Struct):
|
|
18
|
+
media_folder: str | None = None
|
|
19
|
+
roots: dict[str, str] | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def config_dir() -> Path:
|
|
23
|
+
if sys.platform == "win32":
|
|
24
|
+
base = Path(os.environ.get("APPDATA") or Path.home())
|
|
25
|
+
elif sys.platform == "darwin":
|
|
26
|
+
base = Path.home() / "Library" / "Application Support"
|
|
27
|
+
else:
|
|
28
|
+
base = Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
|
|
29
|
+
return base / "mediahive"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def config_path() -> Path:
|
|
33
|
+
return config_dir() / "config.toml"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _migrate_legacy_media_folder(cfg: Config) -> Config:
|
|
37
|
+
"""If roots is empty but media_folder exists, seed roots with it."""
|
|
38
|
+
if cfg.roots:
|
|
39
|
+
return cfg
|
|
40
|
+
if not cfg.media_folder:
|
|
41
|
+
return cfg
|
|
42
|
+
path = Path(cfg.media_folder)
|
|
43
|
+
name = path.name or path.anchor.strip("/\\").lower() or "media"
|
|
44
|
+
# Resolve collisions simply by using the basename; if user had weird layout
|
|
45
|
+
# they can rename via the UI later.
|
|
46
|
+
return msgspec.structs.replace(cfg, roots={name: cfg.media_folder})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_config() -> Config:
|
|
50
|
+
path = config_path()
|
|
51
|
+
if path.exists():
|
|
52
|
+
try:
|
|
53
|
+
cfg = msgspec.toml.decode(path.read_bytes(), type=Config)
|
|
54
|
+
return _migrate_legacy_media_folder(cfg)
|
|
55
|
+
except OSError, msgspec.DecodeError, msgspec.ValidationError:
|
|
56
|
+
return Config()
|
|
57
|
+
return Config()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def save_config(cfg: Config) -> None:
|
|
61
|
+
path = config_path()
|
|
62
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
path.write_bytes(msgspec.toml.encode(cfg))
|
|
Binary file
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<svg width="119" height="140" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="imagebot_9">
|
|
4
|
+
<stop stop-color="#b3925d" id="imagebot_65"/>
|
|
5
|
+
<stop stop-color="#5d4014" id="imagebot_64" offset="1"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
<linearGradient id="imagebot_24">
|
|
8
|
+
<stop stop-color="#fff" id="imagebot_63"/>
|
|
9
|
+
<stop stop-opacity="0" stop-color="#fff" offset="1" id="imagebot_62"/>
|
|
10
|
+
</linearGradient>
|
|
11
|
+
<linearGradient x1="3.83529" y2="0.26302" x2="3.83529" y1="-0.34629" id="imagebot_16" xlink:href="#imagebot_24"/>
|
|
12
|
+
<linearGradient x1="2.18514" y2="0.37278" x2="2.18514" y1="-0.01478" id="imagebot_20" xlink:href="#imagebot_24"/>
|
|
13
|
+
<linearGradient x1="-0.55279" y2="1.01379" x2="-0.55279" y1="-0.01379" id="imagebot_17" xlink:href="#imagebot_9"/>
|
|
14
|
+
<linearGradient y2="1" x2="0.5" y1="0" x1="0.5" id="imagebot_82">
|
|
15
|
+
<stop offset="0" stop-opacity="0.99219" stop-color="#542800"/>
|
|
16
|
+
<stop offset="1" stop-opacity="0.99219" stop-color="#662000"/>
|
|
17
|
+
</linearGradient>
|
|
18
|
+
<linearGradient id="imagebot_83" y2="1" x2="0.5" y1="0" x1="0.5">
|
|
19
|
+
<stop offset="0" stop-opacity="0.99219" stop-color="#542800"/>
|
|
20
|
+
<stop offset="1" stop-opacity="0.99219" stop-color="#662000"/>
|
|
21
|
+
</linearGradient>
|
|
22
|
+
</defs>
|
|
23
|
+
<g>
|
|
24
|
+
<title>Layer 1</title>
|
|
25
|
+
<path id="imagebot_79" stroke-width="0.5" stroke="#57401e" fill-rule="evenodd" fill="url(#imagebot_17)"/>
|
|
26
|
+
<g transform="translate(0 3.06434)" id="imagebot_102">
|
|
27
|
+
<path d="M 22.74422 74.74535044870376 C 31.91926 58.45145044870377 37.64859 41.81313044870377 42.85091 29.248270448703767 C 41.90924 13.406620448703766 52.72854 8.192040448703766 59.79554 8.192040448703766 C 66.86253 8.192040448703766 78.99256 12.669340448703766 76.0848 29.534990448703766 C 80.95944 42.30465044870377 86.3611 59.270660448703765 95.53614 76.05607044870376 C 115.8523 130.92610044870378 73.92952 131.87786044870376 59.79554 131.87786044870376 C 45.66155 131.87786044870376 3.4111000000000002 116.83586044870377 22.74422 74.74535044870376 z" id="imagebot_80" stroke-linecap="null" stroke-linejoin="null" stroke-dasharray="null" stroke="#000000" fill="url(#imagebot_83)"/>
|
|
28
|
+
<path transform="translate(0 0.512) matrix(1 0 0 1 0 -37.4937)" id="imagebot_92" stroke-width="1.143" stroke="url(#imagebot_16)" fill="none" opacity="0.586" d="M 59.47936427844316 46.39889503216985 C 51.40949466195967 46.6597993524647 46.7912370464379 50.28419053324464 44.19117979551757 55.13965915058735 C 50.034283878930864 61.52322215922001 68.81172041094496 64.17701625988963 74.92515422056397 54.788942632751485 C 72.23901202492665 50.05236559006813 67.80056983488612 46.54991700945619 61.10049593995004 46.39889503216985 C 60.54621353239983 46.386403240385086 59.993902535797375 46.38226202213887 59.47936427844316 46.39889503216985 z"/>
|
|
29
|
+
<path transform="translate(0 -0.512) matrix(1 0 0 1 0 -36.9817)" stroke-width="1.113" stroke="#323232" fill-rule="evenodd" fill="#000000" d="M 0.7986898918151866 174.22619862772942 C -0.097920108184816 164.68506862772944 0.9079298918151864 145.87931862772942 14.117579891815183 142.82343862772942 L 57.65248989181518 133.5089986277294 L 103.1211998918152 142.15179862772942 C 116.5566498918152 145.02410862772942 118.02105989181518 163.55207862772943 118.42437989181519 173.9827686277294 C 0.5719998918151816 174.42936862772942 118.47697989181518 173.89226862772944 0.7986898918151866 174.22619862772942 z" id="imagebot_2"/>
|
|
30
|
+
<path stroke-width="1.113" stroke="url(#imagebot_20)" fill="#000000" opacity="0.593" d="M 57.1773 97.07883872083664 L 14.434240000000003 106.71762872083664 L 14.39866 106.71762872083664 C 8.436970000000002 108.11243872083665 5.2424800000000005 112.98359872083665 3.5303200000000032 118.97568872083664 C 1.9480099999999965 124.51329872083664 1.8499800000000022 130.70706872083665 2.1762299999999972 135.29601872083663 C 30.032970000000006 135.21761872083664 45.100809999999996 135.15751872083663 52.241870000000006 135.15347872083663 C 55.91150999999999 135.15147872083665 57.73647 135.18207872083664 58.655969999999996 135.18907872083665 C 59.11572000000001 135.19307872083664 59.32381000000001 135.18307872083665 59.47555 135.18907872083665 C 59.52641 135.19107872083663 59.61632 135.21627872083664 59.68934999999999 135.22467872083664 C 59.69961000000001 135.22501872083663 59.77909 135.22430872083663 59.7962 135.22467872083664 C 59.89894000000001 135.22667872083665 60.04916 135.22367872083663 60.43761000000001 135.22467872083664 C 61.34398999999999 135.22767872083665 63.17347000000001 135.23367872083665 66.85171 135.22467872083664 C 73.98958 135.20697872083664 89.13769000000002 135.15137872083665 116.88172 135.04650872083664 C 116.64753000000002 130.02106872083664 116.11918 123.72255872083664 114.28044 118.19166872083665 C 112.27774 112.16755872083664 108.95868999999999 107.39762872083665 102.87759 106.07614872083664 L 57.1773 97.07883872083664 z" id="imagebot_3"/>
|
|
31
|
+
<path transform="translate(0 -0.512) matrix(1 0 0 1 0 -36.9817)" stroke-width="1.113" stroke="#000000" fill-rule="evenodd" fill="#ffcdb2" d="M 45.24016 124.68690958276748 C 45.47224 130.3192395827675 44.13723 133.56532958276748 41.49851000000001 138.3393595827675 C 46.63544 147.78121958276748 51.76527999999999 153.7549695827675 59.62002 160.2540295827675 C 67.47475 152.82669958276747 73.54878 146.3342795827675 77.70689 137.80701958276748 C 76.45166 134.35116958276748 75.19644 129.66651958276748 73.94121 124.57226958276749 L 45.24016 124.68690958276748 z" id="imagebot_4"/>
|
|
32
|
+
<path transform="translate(0 -0.512) matrix(1 0 0 1 0 -36.9817)" stroke-width="0.976" stroke-linejoin="round" stroke="#000000" fill="#ffcdb2" d="M 87.18131 100.67885958276749 C 87.18131 119.35216958276749 75.33934 134.0977195827675 58.60526 138.1937195827675 C 45.14797999999999 134.0977195827675 32.48680999999999 119.35216958276749 32.48680999999999 100.67885958276749 C 32.48680999999999 82.00555958276749 44.73838000000001 66.85040958276748 59.834059999999994 66.85040958276748 C 74.92974 66.85040958276748 87.18131 82.00555958276749 87.18131 100.67885958276749 z" id="imagebot_5"/>
|
|
33
|
+
<path transform="translate(0 -0.512) matrix(1 0 0 1 0 -36.9817)" stroke="#000000" fill-rule="evenodd" fill="url(#imagebot_82)" d="M 58.73652 60.55180958276749 C 32.629369999999994 61.25627958276748 27.718890000000002 80.5286295827675 29.11278 95.02406958276748 C 29.329250000000002 97.27517958276749 34.27735 131.44748958276747 35.90773 133.26849958276748 C 37.13297 121.83706958276748 35.742279999999994 80.90836958276749 49.40835 74.09559958276749 L 59.11117 77.18910958276749 L 69.46934 74.38438958276748 C 83.18997 80.30845958276748 80.91984 128.0179995827675 81.90542 133.42349958276748 C 83.38494 131.42852958276748 88.61500000000001 96.54846958276748 89.01375000000002 93.86481958276748 C 91.08852000000002 79.90128958276749 83.21866 60.963189582767484 61.350390000000004 60.55180958276749 C 60.4636 60.535119582767486 59.57871 60.52907958276749 58.73652 60.55180958276749 z" id="imagebot_6"/>
|
|
34
|
+
<path transform="translate(0 -0.512) matrix(1 0 0 1 0 -36.9817)" stroke-width="1.143" stroke="url(#imagebot_16)" fill="none" opacity="0.586" d="M 58.762820000000005 61.683199582767486 C 45.99126 62.027819582767485 38.6823 66.81515958276749 34.56739 73.22858958276748 C 43.81482 81.66042958276749 73.53242 85.16573958276749 83.20768 72.76533958276748 C 78.95653 66.50894958276749 71.93215000000001 61.88267958276749 61.32846000000001 61.683199582767486 C 60.45124 61.666699582767485 59.57714 61.66122958276749 58.762820000000005 61.683199582767486 z" id="imagebot_7"/>
|
|
35
|
+
</g>
|
|
36
|
+
</g>
|
|
37
|
+
</svg>
|
|
Binary file
|