440 lines
17 KiB
Python
440 lines
17 KiB
Python
#!/usr/bin/env -S uv run --script
|
|
# /// script
|
|
# requires-python = ">=3.11"
|
|
# dependencies = [
|
|
# "PyQt5==5.15.9",
|
|
# "enzyme==0.5.2",
|
|
# ]
|
|
# ///
|
|
|
|
import sys
|
|
import os
|
|
import json
|
|
import subprocess
|
|
import hashlib
|
|
import enzyme
|
|
from PyQt5.QtWidgets import (
|
|
QApplication, QWidget, QVBoxLayout, QPushButton, QFileDialog, QLabel, QTableWidget, QTableWidgetItem, QHeaderView, QMenu, QTextEdit, QColorDialog
|
|
)
|
|
from PyQt5.QtCore import Qt, QThread, pyqtSignal
|
|
import shutil
|
|
|
|
CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.json')
|
|
CACHE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'cache')
|
|
if not os.path.exists(CACHE_DIR):
|
|
os.makedirs(CACHE_DIR)
|
|
|
|
|
|
def load_config():
|
|
if os.path.exists(CONFIG_FILE):
|
|
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
|
|
return json.load(f)
|
|
return {}
|
|
|
|
def save_config(config):
|
|
with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
|
|
json.dump(config, f, ensure_ascii=False, indent=2)
|
|
|
|
|
|
def list_mkv_files(folder):
|
|
if not folder or not os.path.isdir(folder):
|
|
return []
|
|
return sorted([f for f in os.listdir(folder) if f.lower().endswith('.mkv')], key=str.lower)
|
|
|
|
# Hilfsfunktion für Cache-Dateinamen
|
|
def cache_filename(filename):
|
|
# Dateiname ohne Endung, .json
|
|
base = os.path.splitext(filename)[0]
|
|
return os.path.join(CACHE_DIR, base + '.json')
|
|
|
|
# Prüfen, ob Cache aktuell ist
|
|
def is_cache_valid(mkv_path, cache_path):
|
|
if not os.path.exists(cache_path):
|
|
return False
|
|
try:
|
|
mkv_mtime = os.path.getmtime(mkv_path)
|
|
with open(cache_path, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
return data.get('_mtime') == mkv_mtime
|
|
except Exception:
|
|
return False
|
|
|
|
def clean_codec_name(codec):
|
|
if not codec:
|
|
return ''
|
|
# Entferne bekannte Präfixe
|
|
for prefix in [
|
|
'MPEG4/ISO/', 'MPEGH/ISO/', 'MS/VFW/FOURCC', 'MS/VFW/', 'ISO/', 'V_MPEG4/', 'V_MPEGH/', 'V_', 'MS/', 'A_', 'VFW/']:
|
|
if codec.upper().startswith(prefix):
|
|
return codec[len(prefix):].upper()
|
|
# Nur letzten Teil nach / nehmen, falls vorhanden
|
|
if '/' in codec:
|
|
return codec.split('/')[-1].upper()
|
|
return codec.upper()
|
|
|
|
# ffprobe als Fallback nutzen
|
|
# NEU: enzyme für Audio
|
|
|
|
def enzyme_audio_metadata(path):
|
|
try:
|
|
with open(path, 'rb') as f:
|
|
mkv = enzyme.MKV(f)
|
|
audio_infos = []
|
|
for a in mkv.audio_tracks:
|
|
codec = a.codec_id if hasattr(a, 'codec_id') else a.codec
|
|
# Präfix 'A_' entfernen
|
|
if codec and codec.upper().startswith('A_'):
|
|
codec = codec[2:]
|
|
# MPEG/L3 zu MP3, MPEG/L2 zu MP2
|
|
if codec and codec.upper() == 'MPEG/L3':
|
|
codec = 'MP3'
|
|
elif codec and codec.upper() == 'MPEG/L2':
|
|
codec = 'MP2'
|
|
channels = f"{a.channels}ch" if hasattr(a, 'channels') and a.channels else ''
|
|
sprache = a.language if hasattr(a, 'language') and a.language else ''
|
|
sprache_str = sprache if sprache else 'undefined'
|
|
info = f"{codec} {channels} {sprache_str}".strip()
|
|
audio_infos.append(info)
|
|
return audio_infos, None
|
|
except Exception as e:
|
|
return None, f"enzyme Exception: {e}"
|
|
|
|
def ffprobe_metadata(path):
|
|
try:
|
|
cmd = [
|
|
'ffprobe',
|
|
'-v', 'error',
|
|
'-select_streams', 'v:0',
|
|
'-show_entries', 'stream=width,height,codec_name,codec_tag_string,codec_tag,tags',
|
|
'-of', 'json',
|
|
path
|
|
]
|
|
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
|
if proc.returncode != 0:
|
|
return None, None, None, f"ffprobe Fehler: {proc.stderr.strip()}"
|
|
import json as _json
|
|
data = _json.loads(proc.stdout)
|
|
aufloesung = ''
|
|
video_codec = ''
|
|
debug_codec_candidates = []
|
|
if data.get('streams'):
|
|
s = data['streams'][0]
|
|
if s.get('width') and s.get('height'):
|
|
aufloesung = f"{s['width']}x{s['height']}"
|
|
# Codec-Name ermitteln
|
|
video_codec = s.get('codec_name', '')
|
|
debug_codec_candidates.append(('codec_name', video_codec))
|
|
# Fallback: codec_tag_string, codec_tag, tags
|
|
candidates = [
|
|
s.get('codec_tag_string'),
|
|
s.get('codec_tag'),
|
|
]
|
|
if s.get('tags'):
|
|
tags = s['tags']
|
|
for key in ['codec_tag_string', 'codec_id_hint', 'Codec-ID/Hint', 'codec_tag', 'fourcc', 'handler_name']:
|
|
if tags.get(key):
|
|
candidates.append(tags.get(key))
|
|
debug_codec_candidates.append((key, tags.get(key)))
|
|
# Nimm den ersten sinnvollen Wert, falls codec_name leer oder generisch
|
|
if (not video_codec or video_codec.upper() in ['FOURCC', 'VFW', 'MS', '', 'UNKNOWN']) and any(candidates):
|
|
for c in candidates:
|
|
if c and c.upper() not in ['FOURCC', 'VFW', 'MS', '', 'UNKNOWN']:
|
|
video_codec = c
|
|
break
|
|
video_codec = clean_codec_name(video_codec)
|
|
# Debug: Wenn immer noch leer, logge alle Kandidaten
|
|
if not video_codec or video_codec in ['FOURCC', 'VFW', 'MS', '', 'UNKNOWN']:
|
|
debug_str = f"ffprobe: Keine sinnvolle Codec-Info gefunden. Rohdaten: "
|
|
debug_str += str({k: v for k, v in debug_codec_candidates if v})
|
|
print(debug_str)
|
|
# Audio jetzt mit enzyme
|
|
audio_infos, enzyme_err = enzyme_audio_metadata(path)
|
|
if enzyme_err:
|
|
audio_infos = [f"Fehler: {enzyme_err}"]
|
|
return aufloesung, video_codec, audio_infos, None
|
|
except Exception as e:
|
|
return None, None, None, f"ffprobe Exception: {e}"
|
|
|
|
# Metadaten auslesen und cachen
|
|
class MetadataLoaderThread(QThread):
|
|
files_loaded = pyqtSignal(list)
|
|
debug_message = pyqtSignal(str)
|
|
|
|
def __init__(self, folder, files):
|
|
super().__init__()
|
|
self.folder = folder
|
|
self.files = files
|
|
|
|
def run(self):
|
|
result = []
|
|
for filename in self.files:
|
|
path = os.path.join(self.folder, filename)
|
|
cache_path = cache_filename(filename)
|
|
aufloesung = ''
|
|
video_codec = ''
|
|
audio_infos = []
|
|
# Cache prüfen
|
|
if is_cache_valid(path, cache_path):
|
|
try:
|
|
with open(cache_path, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
aufloesung = data.get('aufloesung', '')
|
|
video_codec = data.get('video_codec', '')
|
|
audio_infos = data.get('audio_infos', [])
|
|
except Exception as e:
|
|
self.debug_message.emit(f"Fehler beim Lesen des Caches für {filename}: {e}")
|
|
else:
|
|
# Nur noch ffprobe
|
|
aufloesung, video_codec, audio_infos, ffprobe_err = ffprobe_metadata(path)
|
|
if ffprobe_err or not aufloesung:
|
|
self.debug_message.emit(f"ffprobe Fehler bei {filename}: {ffprobe_err}")
|
|
aufloesung = 'Fehler'
|
|
video_codec = ''
|
|
audio_infos = [f'Fehler: {ffprobe_err}']
|
|
# Cache schreiben
|
|
try:
|
|
cache_data = {
|
|
'aufloesung': aufloesung,
|
|
'video_codec': video_codec,
|
|
'audio_infos': audio_infos,
|
|
'_mtime': os.path.getmtime(path)
|
|
}
|
|
with open(cache_path, 'w', encoding='utf-8') as f:
|
|
json.dump(cache_data, f, ensure_ascii=False, indent=2)
|
|
except Exception as e:
|
|
self.debug_message.emit(f"Fehler beim Schreiben des Caches für {filename}: {e}")
|
|
# Auflösung und Video-Codec jetzt getrennt
|
|
result.append({
|
|
'filename': filename,
|
|
'aufloesung': aufloesung or '',
|
|
'video_codec': video_codec or '',
|
|
'audio': '; '.join(audio_infos)
|
|
})
|
|
self.files_loaded.emit(result)
|
|
|
|
class MovieChecker(QWidget):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.setWindowTitle('Movie Checker')
|
|
self.resize(800, 500)
|
|
self.config = load_config()
|
|
self.folder = self.config.get('movie_folder', '')
|
|
|
|
self.layout = QVBoxLayout()
|
|
self.setLayout(self.layout)
|
|
|
|
self.info_label = QLabel('Wähle einen Ordner mit MKV-Dateien:')
|
|
self.layout.addWidget(self.info_label)
|
|
|
|
self.select_btn = QPushButton('Ordner auswählen')
|
|
self.select_btn.clicked.connect(self.select_folder)
|
|
self.layout.addWidget(self.select_btn)
|
|
|
|
self.reload_btn = QPushButton('Cache leeren & neu einlesen')
|
|
self.reload_btn.clicked.connect(self.reload_and_clear_cache)
|
|
self.layout.addWidget(self.reload_btn)
|
|
|
|
self.loading_label = QLabel('')
|
|
self.loading_label.setAlignment(Qt.AlignCenter)
|
|
self.layout.addWidget(self.loading_label)
|
|
|
|
self.table = QTableWidget(0, 4)
|
|
self.table.setHorizontalHeaderLabels(['Datei', 'Auflösung', 'Video-Codec', 'Audio'])
|
|
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
|
|
self.table.setEditTriggers(QTableWidget.NoEditTriggers)
|
|
self.table.setContextMenuPolicy(Qt.CustomContextMenu)
|
|
self.table.customContextMenuRequested.connect(self.show_table_context_menu)
|
|
self.layout.addWidget(self.table)
|
|
|
|
self.debug_log = QTextEdit()
|
|
self.debug_log.setReadOnly(True)
|
|
self.debug_log.setMaximumHeight(120)
|
|
self.layout.addWidget(self.debug_log)
|
|
|
|
self.file_loader = None
|
|
self.meta_loader = None
|
|
self.spinner_active = False
|
|
self.spinner_frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏']
|
|
self.spinner_index = 0
|
|
self.timer = None
|
|
|
|
self.load_files()
|
|
|
|
def select_folder(self):
|
|
folder = QFileDialog.getExistingDirectory(self, 'Filme-Ordner auswählen', self.folder or os.path.expanduser('~'))
|
|
if folder:
|
|
self.folder = folder
|
|
self.config['movie_folder'] = folder
|
|
save_config(self.config)
|
|
self.load_files()
|
|
|
|
def reload_and_clear_cache(self):
|
|
# Cache-Ordner leeren
|
|
if os.path.exists(CACHE_DIR):
|
|
for f in os.listdir(CACHE_DIR):
|
|
try:
|
|
os.remove(os.path.join(CACHE_DIR, f))
|
|
except Exception:
|
|
pass
|
|
self.load_files()
|
|
|
|
def start_spinner(self, text='Lade Dateien ...'):
|
|
if not self.spinner_active:
|
|
self.spinner_active = True
|
|
self.spinner_index = 0
|
|
from PyQt5.QtCore import QTimer
|
|
self.timer = QTimer(self)
|
|
self.timer.timeout.connect(lambda: self.update_spinner(text))
|
|
self.timer.start(80)
|
|
self.loading_label.setText(self.spinner_frames[self.spinner_index] + ' ' + text)
|
|
|
|
def stop_spinner(self):
|
|
if self.spinner_active and self.timer:
|
|
self.timer.stop()
|
|
self.spinner_active = False
|
|
self.loading_label.setText('')
|
|
|
|
def update_spinner(self, text):
|
|
self.spinner_index = (self.spinner_index + 1) % len(self.spinner_frames)
|
|
self.loading_label.setText(self.spinner_frames[self.spinner_index] + ' ' + text)
|
|
|
|
def log_debug(self, msg):
|
|
self.debug_log.append(msg)
|
|
|
|
def load_files(self):
|
|
self.table.setRowCount(0)
|
|
if not self.folder:
|
|
self.info_label.setText('Kein Ordner ausgewählt.')
|
|
return
|
|
self.info_label.setText(f'Aktiver Ordner: {self.folder}')
|
|
self.start_spinner('Lade Dateiliste ...')
|
|
files = list_mkv_files(self.folder)
|
|
self.on_files_loaded(files)
|
|
|
|
def on_files_loaded(self, files):
|
|
self.stop_spinner()
|
|
self.table.setRowCount(0)
|
|
self.debug_log.clear()
|
|
if not files:
|
|
self.table.setRowCount(1)
|
|
self.table.setItem(0, 0, QTableWidgetItem('Keine MKV-Dateien gefunden.'))
|
|
self.table.setItem(0, 1, QTableWidgetItem(''))
|
|
self.table.setItem(0, 2, QTableWidgetItem(''))
|
|
self.table.setItem(0, 3, QTableWidgetItem(''))
|
|
else:
|
|
self.start_spinner('Lese Metadaten ...')
|
|
self.meta_loader = MetadataLoaderThread(self.folder, files)
|
|
self.meta_loader.files_loaded.connect(self.on_metadata_loaded)
|
|
self.meta_loader.debug_message.connect(self.log_debug)
|
|
self.meta_loader.start()
|
|
|
|
def on_metadata_loaded(self, file_infos):
|
|
self.stop_spinner()
|
|
self.table.setRowCount(len(file_infos))
|
|
for row, info in enumerate(file_infos):
|
|
self.table.setItem(row, 0, QTableWidgetItem(info['filename']))
|
|
aufl_item = QTableWidgetItem(info['aufloesung'])
|
|
# Farbige Unterlegung je nach Breite
|
|
breite = None
|
|
try:
|
|
if info['aufloesung']:
|
|
breite = int(str(info['aufloesung']).split('x')[0])
|
|
except Exception:
|
|
pass
|
|
if breite is not None:
|
|
if breite < 1280:
|
|
aufl_item.setBackground(Qt.red)
|
|
elif breite < 1920:
|
|
aufl_item.setBackground(Qt.darkYellow)
|
|
elif breite < 3840:
|
|
aufl_item.setBackground(Qt.yellow)
|
|
else:
|
|
aufl_item.setBackground(Qt.green)
|
|
self.table.setItem(row, 1, aufl_item)
|
|
self.table.setItem(row, 2, QTableWidgetItem(info.get('video_codec', '')))
|
|
self.table.setItem(row, 3, QTableWidgetItem(info['audio']))
|
|
|
|
def show_table_context_menu(self, pos):
|
|
index = self.table.indexAt(pos)
|
|
if not index.isValid():
|
|
return
|
|
row = index.row()
|
|
col = index.column()
|
|
filename_item = self.table.item(row, 0)
|
|
if not filename_item:
|
|
return
|
|
filename = filename_item.text()
|
|
if filename == 'Keine MKV-Dateien gefunden.':
|
|
return
|
|
menu = QMenu(self)
|
|
reload_action = menu.addAction('Film neu einlesen (Cache löschen)')
|
|
# Option für Farbe wählen, nur für Auflösungsspalte
|
|
color_action = None
|
|
if col == 1:
|
|
color_action = menu.addAction('Farbe für diese Zelle wählen ...')
|
|
action = menu.exec_(self.table.viewport().mapToGlobal(pos))
|
|
if action == reload_action:
|
|
self.reload_single_file(filename)
|
|
elif color_action and action == color_action:
|
|
self.choose_cell_color(row, col)
|
|
|
|
def choose_cell_color(self, row, col):
|
|
item = self.table.item(row, col)
|
|
if not item:
|
|
return
|
|
color = QColorDialog.getColor()
|
|
if color.isValid():
|
|
item.setBackground(color)
|
|
|
|
def reload_single_file(self, filename):
|
|
# Cache-Datei löschen
|
|
cache_path = cache_filename(filename)
|
|
if os.path.exists(cache_path):
|
|
try:
|
|
os.remove(cache_path)
|
|
except Exception:
|
|
pass
|
|
# Nur diesen Film neu laden
|
|
self.start_spinner(f'Lese Metadaten für {filename} ...')
|
|
self.meta_loader = MetadataLoaderThread(self.folder, [filename])
|
|
self.meta_loader.files_loaded.connect(lambda infos: self.update_single_row(filename, infos[0] if infos else None))
|
|
self.meta_loader.debug_message.connect(self.log_debug)
|
|
self.meta_loader.start()
|
|
|
|
def update_single_row(self, filename, info):
|
|
self.stop_spinner()
|
|
for row in range(self.table.rowCount()):
|
|
if self.table.item(row, 0) and self.table.item(row, 0).text() == filename:
|
|
if info:
|
|
self.table.setItem(row, 0, QTableWidgetItem(info['filename']))
|
|
aufl_item = QTableWidgetItem(info['aufloesung'])
|
|
breite = None
|
|
try:
|
|
if info['aufloesung']:
|
|
breite = int(str(info['aufloesung']).split('x')[0])
|
|
except Exception:
|
|
pass
|
|
if breite is not None:
|
|
if breite < 1280:
|
|
aufl_item.setBackground(Qt.red)
|
|
elif breite < 1920:
|
|
aufl_item.setBackground(Qt.darkYellow)
|
|
elif breite < 3840:
|
|
aufl_item.setBackground(Qt.yellow)
|
|
else:
|
|
aufl_item.setBackground(Qt.green)
|
|
self.table.setItem(row, 1, aufl_item)
|
|
self.table.setItem(row, 2, QTableWidgetItem(info.get('video_codec', '')))
|
|
self.table.setItem(row, 3, QTableWidgetItem(info['audio']))
|
|
break
|
|
|
|
|
|
def main():
|
|
app = QApplication(sys.argv)
|
|
window = MovieChecker()
|
|
window.show()
|
|
sys.exit(app.exec_())
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main() |