adb music sync v2
This commit is contained in:
		
							
								
								
									
										246
									
								
								adb-music-sync/adb-music-sync.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										246
									
								
								adb-music-sync/adb-music-sync.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,246 @@ | ||||
| #!/usr/bin/env python3 | ||||
| import os | ||||
| import sys | ||||
| import time | ||||
| import shlex | ||||
| import subprocess | ||||
| import tempfile | ||||
| from pathlib import Path | ||||
| from typing import List, Tuple, Optional | ||||
|  | ||||
| # --- Configuration --- | ||||
| HOME = Path.home() | ||||
| SOURCES = [ | ||||
|     HOME / "Music", | ||||
|     HOME / "Downloads", | ||||
| ] | ||||
| DEST_ON_PHONE = "/sdcard/Music" | ||||
| # Model tag as seen in `adb devices -l` (e.g., ... model:AGM_M7 ...) | ||||
| ADB_MODEL_MATCH = ["AGM_M7", "AGM", "M7"] | ||||
| POLL_INTERVAL_SEC = 60 | ||||
| SIZE_LIMIT_BYTES = 7 * 1024 ** 3  # 7 GiB | ||||
| MUSIC_EXTS = {".mp3", ".m4a", ".ogg", ".aac", ".opus", ".alac", ".flac"} | ||||
|  | ||||
| # If you have a static Wi-Fi ADB endpoint, set it here (e.g., "192.168.1.50:5555"). | ||||
| ADB_IP_PORT: Optional[str] = None | ||||
|  | ||||
| # --- Helpers --- | ||||
| def run(cmd: List[str], check: bool = False, text: bool = True, timeout: Optional[int] = None) -> subprocess.CompletedProcess: | ||||
|     try: | ||||
|         return subprocess.run(cmd, check=check, text=text, capture_output=True, timeout=timeout) | ||||
|     except Exception as e: | ||||
|         return subprocess.CompletedProcess(cmd, returncode=1, stdout="", stderr=str(e)) | ||||
|  | ||||
|  | ||||
| def ensure_adb_server() -> None: | ||||
|     run(["adb", "start-server"])  # best-effort | ||||
|  | ||||
|  | ||||
| def list_adb_devices() -> List[str]: | ||||
|     proc = run(["adb", "devices", "-l"])  # do not check, we parse regardless | ||||
|     if proc.returncode != 0: | ||||
|         return [] | ||||
|     lines = [ln.strip() for ln in proc.stdout.splitlines()] | ||||
|     # skip header line: "List of devices attached" | ||||
|     if lines and lines[0].lower().startswith("list of devices"): | ||||
|         lines = lines[1:] | ||||
|     return [ln for ln in lines if ln] | ||||
|  | ||||
|  | ||||
| def adb_connect_if_needed(): | ||||
|     if not ADB_IP_PORT: | ||||
|         return | ||||
|     devices = list_adb_devices() | ||||
|     if any(ADB_IP_PORT in ln for ln in devices): | ||||
|         return  # already connected | ||||
|     run(["adb", "connect", ADB_IP_PORT]) | ||||
|  | ||||
|  | ||||
| def is_agm_connected() -> bool: | ||||
|     devices = list_adb_devices() | ||||
|     for ln in devices: | ||||
|         if "device" in ln and not ln.endswith("offline"): | ||||
|             if any(tag in ln for tag in ADB_MODEL_MATCH): | ||||
|                 return True | ||||
|     return False | ||||
|  | ||||
|  | ||||
| def gather_music_files() -> List[Tuple[Path, Path, float, int]]: | ||||
|     # Returns list of tuples: (root_dir, file_path, mtime, size) | ||||
|     entries: List[Tuple[Path, Path, float, int]] = [] | ||||
|     for root in SOURCES: | ||||
|         try: | ||||
|             if not root.exists(): | ||||
|                 continue | ||||
|             for dirpath, _, filenames in os.walk(root): | ||||
|                 dpath = Path(dirpath) | ||||
|                 for fn in filenames: | ||||
|                     p = dpath / fn | ||||
|                     if p.suffix.lower() not in MUSIC_EXTS: | ||||
|                         continue | ||||
|                     try: | ||||
|                         st = p.stat() | ||||
|                         entries.append((root, p, st.st_mtime, st.st_size)) | ||||
|                     except Exception: | ||||
|                         continue | ||||
|         except Exception: | ||||
|             continue | ||||
|     # newest first by mtime | ||||
|     entries.sort(key=lambda t: t[2], reverse=True) | ||||
|     return entries | ||||
|  | ||||
|  | ||||
| def select_up_to_size(entries: List[Tuple[Path, Path, float, int]], limit_bytes: int) -> List[Tuple[Path, Path, int]]: | ||||
|     selected: List[Tuple[Path, Path, int]] = [] | ||||
|     total = 0 | ||||
|     for root, p, _mt, sz in entries: | ||||
|         if total >= limit_bytes: | ||||
|             break | ||||
|         selected.append((root, p, sz)) | ||||
|         total += sz | ||||
|     return selected | ||||
|  | ||||
|  | ||||
| def adb_shell(cmd: str) -> subprocess.CompletedProcess: | ||||
|     # Single string executed via adb shell | ||||
|     return run(["adb", "shell", cmd]) | ||||
|  | ||||
|  | ||||
| def remote_size(adb_path: str) -> Optional[int]: | ||||
|     # Uses stat if available; returns None if missing | ||||
|     # Escape path for shell | ||||
|     qpath = shlex.quote(adb_path) | ||||
|     proc = adb_shell(f"stat -c %s {qpath}") | ||||
|     if proc.returncode != 0 or not proc.stdout.strip(): | ||||
|         return None | ||||
|     out = proc.stdout.strip() | ||||
|     if "No such file" in out or "not found" in out: | ||||
|         return None | ||||
|     try: | ||||
|         return int(out.splitlines()[-1].strip()) | ||||
|     except Exception: | ||||
|         return None | ||||
|  | ||||
|  | ||||
| def ensure_remote_dir(adb_path: str) -> None: | ||||
|     # mkdir -p dirname | ||||
|     dirname = os.path.dirname(adb_path) | ||||
|     if not dirname: | ||||
|         return | ||||
|     adb_shell(f"mkdir -p {shlex.quote(dirname)}") | ||||
|  | ||||
|  | ||||
| def have_ffmpeg() -> bool: | ||||
|     proc = run(["ffmpeg", "-version"]) | ||||
|     return proc.returncode == 0 | ||||
|  | ||||
|  | ||||
| def transcode_flac_to_mp3(src: Path) -> Optional[Path]: | ||||
|     try: | ||||
|         tmp = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) | ||||
|         tmp_path = Path(tmp.name) | ||||
|         tmp.close() | ||||
|     except Exception: | ||||
|         return None | ||||
|     cmd = [ | ||||
|         "ffmpeg", | ||||
|         "-y", | ||||
|         "-i", | ||||
|         str(src), | ||||
|         "-vn", | ||||
|         "-c:a", | ||||
|         "libmp3lame", | ||||
|         "-q:a", | ||||
|         "2", | ||||
|         str(tmp_path), | ||||
|     ] | ||||
|     proc = run(cmd) | ||||
|     if proc.returncode != 0: | ||||
|         try: | ||||
|             tmp_path.unlink(missing_ok=True)  # type: ignore[arg-type] | ||||
|         except Exception: | ||||
|             pass | ||||
|         return None | ||||
|     return tmp_path | ||||
|  | ||||
|  | ||||
| def push_file(local_path: Path, remote_path: str, size: int) -> bool: | ||||
|     # Skip if same size exists | ||||
|     rsz = remote_size(remote_path) | ||||
|     if rsz is not None and rsz == size: | ||||
|         print(f"[=] Skip (same size): {local_path} -> {remote_path}") | ||||
|         return True | ||||
|     ensure_remote_dir(remote_path) | ||||
|     proc = run(["adb", "push", "-p", str(local_path), remote_path]) | ||||
|     if proc.returncode == 0: | ||||
|         print(f"[+] Pushed: {local_path} -> {remote_path}") | ||||
|         return True | ||||
|     else: | ||||
|         print(f"[!] Push failed: {local_path}\n{proc.stderr}") | ||||
|         return False | ||||
|  | ||||
|  | ||||
| def sync_latest_batch(): | ||||
|     entries = gather_music_files() | ||||
|     if not entries: | ||||
|         print("[i] No music files found.") | ||||
|         return | ||||
|     batch = select_up_to_size(entries, SIZE_LIMIT_BYTES) | ||||
|     print(f"[i] Selected {len(batch)} files up to {SIZE_LIMIT_BYTES/(1024**3):.2f} GiB") | ||||
|     for root, p, sz in batch: | ||||
|         try: | ||||
|             rel = p.relative_to(root) | ||||
|         except Exception: | ||||
|             rel = p.name  # fallback | ||||
|         if p.suffix.lower() == ".flac": | ||||
|             if not have_ffmpeg(): | ||||
|                 print(f"[!] ffmpeg not available, skipping: {p}") | ||||
|                 continue | ||||
|             mp3_tmp = transcode_flac_to_mp3(p) | ||||
|             if not mp3_tmp: | ||||
|                 print(f"[!] Transcode failed, skipping: {p}") | ||||
|                 continue | ||||
|             try: | ||||
|                 if isinstance(rel, Path): | ||||
|                     rel_mp3 = rel.with_suffix(".mp3") | ||||
|                     remote = f"{DEST_ON_PHONE.rstrip('/')}/{rel_mp3.as_posix()}" | ||||
|                 else: | ||||
|                     remote = f"{DEST_ON_PHONE.rstrip('/')}/{Path(rel).with_suffix('.mp3').as_posix()}" | ||||
|                 msz = mp3_tmp.stat().st_size | ||||
|                 push_file(mp3_tmp, remote, msz) | ||||
|             finally: | ||||
|                 try: | ||||
|                     mp3_tmp.unlink() | ||||
|                 except Exception: | ||||
|                     pass | ||||
|         else: | ||||
|             remote = f"{DEST_ON_PHONE.rstrip('/')}/{rel.as_posix()}" | ||||
|             push_file(p, remote, sz) | ||||
|  | ||||
|  | ||||
| def main(): | ||||
|     print("[i] adb-music-sync daemon starting...") | ||||
|     ensure_adb_server() | ||||
|     connected_prev = False | ||||
|     while True: | ||||
|         try: | ||||
|             ensure_adb_server() | ||||
|             adb_connect_if_needed() | ||||
|             connected = is_agm_connected() | ||||
|             if connected and not connected_prev: | ||||
|                 print("[+] AGM M7 connected. Starting sync of latest ~7 GiB...") | ||||
|                 sync_latest_batch() | ||||
|             elif not connected and connected_prev: | ||||
|                 print("[-] AGM M7 disconnected.") | ||||
|             connected_prev = connected | ||||
|             time.sleep(POLL_INTERVAL_SEC) | ||||
|         except KeyboardInterrupt: | ||||
|             print("[i] Exiting on user interrupt.") | ||||
|             sys.exit(0) | ||||
|         except Exception as e: | ||||
|             print(f"[!] Error in main loop: {e}") | ||||
|             time.sleep(POLL_INTERVAL_SEC) | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     main() | ||||
							
								
								
									
										78
									
								
								adb-music-sync/install.sh
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								adb-music-sync/install.sh
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,78 @@ | ||||
| #!/usr/bin/env bash | ||||
| set -euo pipefail | ||||
|  | ||||
| SILENT=false | ||||
| LOG_PREFIX="[adb-music-sync]" | ||||
|  | ||||
| PYTHON_SCRIPT="$HOME/.linux-env/adb-music-sync/adb-music-sync.py" | ||||
| INSTALL_TARGET="$HOME/.local/bin/adb-music-sync.py" | ||||
| AUTOSTART_DIR="$HOME/.config/autostart" | ||||
| AUTOSTART_FILE="$AUTOSTART_DIR/adb-music-sync.desktop" | ||||
|  | ||||
| log() { | ||||
|   if [ "$SILENT" = true ]; then return; fi | ||||
|   echo "$LOG_PREFIX $*" | ||||
| } | ||||
|  | ||||
| check_and_install_deps() { | ||||
|   # Minimal dependency: adb | ||||
|   if ! command -v adb >/dev/null 2>&1; then | ||||
|     if command -v apt >/dev/null 2>&1; then | ||||
|       log "[+] Installation d'adb (apt) ..." | ||||
|       sudo apt update -y && sudo apt install -y adb | ||||
|     elif command -v dnf >/dev/null 2>&1; then | ||||
|       log "[+] Installation d'adb (dnf) ..." | ||||
|       sudo dnf install -y android-tools | ||||
|     elif command -v pacman >/dev/null 2>&1; then | ||||
|       log "[+] Installation d'adb (pacman) ..." | ||||
|       sudo pacman -Sy --noconfirm android-tools | ||||
|     else | ||||
|       log "[!] adb introuvable et gestionnaire de paquets inconnu. Installez adb manuellement." | ||||
|     fi | ||||
|   fi | ||||
| } | ||||
|  | ||||
| install_script() { | ||||
|   check_and_install_deps | ||||
|  | ||||
|   log "[+] Copie du script Python..." | ||||
|   mkdir -p "$(dirname "$INSTALL_TARGET")" | ||||
|   cp "$PYTHON_SCRIPT" "$INSTALL_TARGET" | ||||
|   chmod +x "$INSTALL_TARGET" | ||||
|  | ||||
|   log "[+] Création du fichier autostart..." | ||||
|   mkdir -p "$AUTOSTART_DIR" | ||||
|   cat > "$AUTOSTART_FILE" <<EOF | ||||
| [Desktop Entry] | ||||
| Type=Application | ||||
| Exec=$INSTALL_TARGET | ||||
| Hidden=false | ||||
| NoDisplay=false | ||||
| X-GNOME-Autostart-enabled=true | ||||
| Name=ADB Music Sync | ||||
| Comment=Lance la synchronisation de ~5GiB de musique vers l'AGM M7 à la connexion ADB | ||||
| EOF | ||||
|  | ||||
|   log "[✓] Installation terminée. Le script se lancera automatiquement à l'ouverture de session." | ||||
| } | ||||
|  | ||||
| uninstall_script() { | ||||
|   log "[+] Suppression du script Python et autostart..." | ||||
|   rm -f "$INSTALL_TARGET" | ||||
|   rm -f "$AUTOSTART_FILE" | ||||
|   log "[✓] Désinstallation terminée." | ||||
| } | ||||
|  | ||||
| case "${1:-}" in | ||||
|   uninstall) | ||||
|     uninstall_script | ||||
|     ;; | ||||
|   silent) | ||||
|     SILENT=true | ||||
|     uninstall_script &>/dev/null || true | ||||
|     install_script &>/dev/null | ||||
|     ;; | ||||
|   *) | ||||
|     install_script | ||||
|     ;; | ||||
| esac | ||||
		Reference in New Issue
	
	Block a user