7 Commits
7 changed files with 135 additions and 59 deletions
+46 -7
View File
@@ -12,28 +12,41 @@ import sys
from pathlib import Path
import shutil
def build():
system = platform.system()
project_dir = Path(__file__).parent
dist_dir = project_dir / "dist"
# Alte App löschen
app_name = "fcco-Converter"
if system == "Darwin":
old_app = dist_dir / f"{app_name}.app"
else:
old_app = dist_dir / f"{app_name}.exe"
if old_app.exists():
if old_app.is_dir():
shutil.rmtree(old_app)
else:
old_app.unlink()
# Basis-Argumente für PyInstaller
args = [
sys.executable,
"-m", "PyInstaller",
"--onefile",
"--windowed",
"--name", "fcco-Converter",
"--name", app_name,
"--clean",
]
# Icon hinzufügen (wenn vorhanden)
icon_path = project_dir / "icon.ico"
if icon_path.exists():
if system == "Windows":
args.extend(["--icon", str(icon_path)])
elif system == "Darwin":
# macOS braucht .icns .ico funktioniert trotzdem als Fallback
args.extend(["--icon", str(icon_path)])
args.extend(["--icon", str(icon_path)])
# Versteckte Imports (die PyInstaller evtl. nicht automatisch findet)
hidden_imports = [
@@ -66,8 +79,34 @@ def build():
result = subprocess.run(args, cwd=str(project_dir))
if result.returncode == 0:
# Kopiere config.json in den dist Ordner, damit die App sie direkt findet
config_src = project_dir / "config.json"
if config_src.exists():
shutil.copy(config_src, dist_dir / "config.json")
# ZIP-Archiv für das Release erstellen
import tempfile
zip_name = f"fcco-Converter-{system.lower()}"
zip_path = dist_dir / zip_name
with tempfile.TemporaryDirectory() as tmpdir:
tmp = Path(tmpdir)
# App/Exe ins Temp-Verzeichnis kopieren
if system == "Darwin":
shutil.copytree(dist_dir / f"{app_name}.app", tmp / f"{app_name}.app")
else:
shutil.copy(dist_dir / f"{app_name}.exe", tmp / f"{app_name}.exe")
# config.json mit in die ZIP packen
if config_src.exists():
shutil.copy(config_src, tmp / "config.json")
shutil.make_archive(str(zip_path), 'zip', str(tmp))
print()
print(f"Build erfolgreich! Ausgabe in: {project_dir / 'dist'}")
print(f"Build erfolgreich! Ausgabe in: {dist_dir}")
print(f"ZIP für Release: {zip_path}.zip")
else:
print()
print(f"Build fehlgeschlagen (Code {result.returncode}).")
+2 -2
View File
@@ -1,7 +1,7 @@
{
"app_name": "fcco Converter",
"app_version": "2.0.0",
"app_version": "2.0.1",
"repo_owner": "public",
"repo_name": "fcco-Converter",
"gitea_api_base": "https://git.joelunger.de/api/v1"
}
}
+5
View File
@@ -23,7 +23,12 @@ def _load_app_info():
try:
# Finde config.json relativ zum Skript oder im PyInstaller Bundle
if getattr(sys, "frozen", False):
# Suche ausgehend von der Executable aufwärts nach config.json
base_dir = Path(sys.executable).parent
while base_dir.name and str(base_dir) != base_dir.root:
if (base_dir / "config.json").exists():
break
base_dir = base_dir.parent
else:
base_dir = Path(__file__).parent.parent
+22 -2
View File
@@ -115,6 +115,9 @@ def _build_ffmpeg_cmd(
else:
cmd.append("-n")
# Keine Video/Bilder-Streams übernehmen (verhindert Konflikte mit eigenen Covern)
cmd.append("-vn")
cmd.append(str(output_path))
return cmd
@@ -190,6 +193,8 @@ def convert_file_entry(
audio_settings: dict | None = None,
threads: int = 0,
progress_callback: Callable[[int, int, str], None] | None = None,
cover_data: bytes | None = None,
cover_mime: str = "image/jpeg",
) -> tuple[int, str, str | None]:
"""Konvertiert eine Datei und schreibt die Metadaten.
@@ -227,7 +232,6 @@ def convert_file_entry(
# ID3-Metadaten nur für MP3 schreiben
if output_format == "mp3":
cover_path = Path(meta["cover"]) if meta.get("cover") else None
apply_id3_tags(
mp3_path=output_file,
title=title,
@@ -235,7 +239,8 @@ def convert_file_entry(
album=meta.get("album", ""),
track_number=index + 1,
year=meta.get("year", ""),
cover_path=cover_path,
cover_data=cover_data,
cover_mime=cover_mime,
)
return (index, f"{title}.{output_format}", None)
@@ -266,6 +271,19 @@ def run_batch_conversion(
successful_files = []
errors = []
# Cover-Bild einmalig in den Arbeitsspeicher laden, um Dateisperren (Locking)
# bei parallelem Zugriff durch Windows Defender/Antivirus zu vermeiden
cover_data = None
cover_mime = "image/jpeg"
cover_path_str = meta.get("cover")
if cover_path_str:
cp = Path(cover_path_str)
if cp.exists():
if cp.suffix.lower() == ".png":
cover_mime = "image/png"
with open(cp, "rb") as f:
cover_data = f.read()
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(
@@ -280,6 +298,8 @@ def run_batch_conversion(
audio_settings=audio_settings,
threads=0,
progress_callback=progress_callback,
cover_data=cover_data,
cover_mime=cover_mime,
): idx
for idx, entry in enumerate(entries)
}
+26 -24
View File
@@ -51,6 +51,8 @@ def extract_event_from_folder(file_path: Path) -> tuple[str, str]:
return "", ""
from mutagen.id3 import ID3, APIC, TIT2, TPE1, TALB, TRCK, TDRC
def apply_id3_tags(
mp3_path: Path,
title: str,
@@ -58,31 +60,31 @@ def apply_id3_tags(
album: str,
track_number: int,
year: str,
cover_path: Path | None = None,
cover_data: bytes | None = None,
cover_mime: str = "image/jpeg",
) -> None:
"""Schreibt ID3-Metadaten und optional ein Cover in die MP3-Datei."""
audiofile = EasyID3(str(mp3_path))
audiofile["title"] = title
audiofile["artist"] = artist or "Unbekannt"
audiofile["album"] = album or "Unbekanntes Album"
audiofile["tracknumber"] = str(track_number)
audiofile["date"] = year
audiofile.save()
try:
tags = ID3(str(mp3_path))
except Exception:
tags = ID3()
if cover_path and cover_path.exists():
audio_id3 = ID3(str(mp3_path))
mime = "image/jpeg"
suffix = cover_path.suffix.lower()
if suffix == ".png":
mime = "image/png"
with open(cover_path, "rb") as img:
audio_id3.add(
APIC(
encoding=3,
mime=mime,
type=3,
desc="Cover",
data=img.read(),
)
tags.add(TIT2(encoding=3, text=title))
tags.add(TPE1(encoding=3, text=artist or "Unbekannt"))
tags.add(TALB(encoding=3, text=album or "Unbekanntes Album"))
tags.add(TRCK(encoding=3, text=str(track_number)))
if year:
tags.add(TDRC(encoding=3, text=year))
if cover_data:
tags.add(
APIC(
encoding=3,
mime=cover_mime,
type=3,
desc="Cover",
data=cover_data,
)
audio_id3.save()
)
tags.save(str(mp3_path), v2_version=3)
+34 -24
View File
@@ -39,36 +39,46 @@ def _fetch_json(url: str) -> list | dict:
return json.loads(resp.read().decode("utf-8"))
def check_for_updates() -> dict | None:
"""Prüft, ob ein neues Release verfügbar ist.
def _parse_version(v: str) -> list[int]:
parts = []
for p in v.lstrip("vV").split("."):
try:
parts.append(int(p))
except ValueError:
parts.append(0)
while len(parts) < 3:
parts.append(0)
return parts
def check_for_updates() -> dict | None:
"""Prüft, ob auf Gitea ein neues Release (nach SemVer) vorliegt.
Returns:
Dict mit Release-Infos (tag_name, name, body, assets) oder None.
Das Release-Dict (von Gitea) wenn ein Update verfügbar ist, sonst None.
"""
try:
releases = _fetch_json(RELEASES_URL)
if not releases:
req = Request(RELEASES_URL, headers={'User-Agent': 'fcco-Updater/1.0'})
with urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode('utf-8'))
if not data:
return None
# Neuestes (nicht als Draft/Prerelease markiertes) Release
for release in releases:
if release.get("draft") or release.get("prerelease"):
continue
tag = release.get("tag_name", "").lstrip("v")
if tag and tag != CURRENT_VERSION:
# Einfacher Versionsvergleich
try:
remote_parts = [int(x) for x in tag.split(".")]
local_parts = [int(x) for x in CURRENT_VERSION.split(".")]
if remote_parts > local_parts:
return release
except ValueError:
continue
break # Nur das neueste Release prüfen
# Gitea gibt Releases sortiert zurück, 0 = neuestes
release_info = data[0]
tag_name = release_info.get("tag_name", "")
# Entferne eventuelles "v" Präfix
latest_version = tag_name.lstrip("vV")
# Intelligenter Versions-Vergleich
if _parse_version(latest_version) > _parse_version(CURRENT_VERSION):
return release_info
return None
except (URLError, json.JSONDecodeError, OSError):
except Exception as e:
print(f"Update Check Error: {e}")
return None
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB