diff --git a/.github/workflows/update-formula.yml b/.github/workflows/update-formula.yml new file mode 100644 index 0000000..1fbd60f --- /dev/null +++ b/.github/workflows/update-formula.yml @@ -0,0 +1,36 @@ +name: Update Homebrew Formula + +on: + release: + types: [published] + +jobs: + update-formula: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + - name: Get release version + id: release + run: | + VERSION=${GITHUB_REF#refs/tags/v} + echo "version=$VERSION" >> $GITHUB_OUTPUT + - name: Download tarball and compute SHA256 + run: | + curl -sL https://github.com/${{ github.repository }}/archive/refs/tags/v${{ steps.release.outputs.version }}.tar.gz -o tarball.tar.gz + SHA256=$(shasum -a 256 tarball.tar.gz | cut -d' ' -f1) + echo "sha256=$SHA256" >> $GITHUB_ENV + sed -i "s|v1\.0\.2|v${{ steps.release.outputs.version }}|g" Formula/songfetch.rb + sed -i "s|PLACEHOLDER_SHA256|$SHA256|" Formula/songfetch.rb + - name: Commit and push changes + run: | + git add Formula/songfetch.rb + if git diff --cached --quiet; then + echo "No changes to commit" + else + git config --global user.name 'GitHub Actions' + git config --global user.email 'actions@github.com' + git commit -m "Update formula to v${{ steps.release.outputs.version }}" + git push + fi diff --git a/.gitignore b/.gitignore index b080932..840820e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ venv/ # Python cache __pycache__/ + +# egg +*.egg-info/ diff --git a/Formula/songfetch.rb b/Formula/songfetch.rb new file mode 100644 index 0000000..733b756 --- /dev/null +++ b/Formula/songfetch.rb @@ -0,0 +1,17 @@ +class Songfetch < Formula + desc "A CLI tool that displays current song information in the terminal" + homepage "https://github.com/fwtwoo/songfetch" + url "https://github.com/fwtwoo/songfetch/archive/refs/tags/v1.0.2.tar.gz" + sha256 "1b4c73283d7b5981b314ae3f77b6150947077aa505ab8869bb12bce0df6f7bf4" + license "GPL-2.0" + + depends_on "python@3.14" + + def install + system "python3", "-m", "pip", "install", "--prefix=#{prefix}", "." + end + + test do + system "#{bin}/songfetch", "--help" + end +end \ No newline at end of file diff --git a/README.md b/README.md index acbc7da..a51fce0 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,13 @@ Then install the full program: yay -S songfetch ``` +## Installation (Homebrew/MacOS): + +```bash +brew tap fwtwoo/songfetch https://github.com/fwtwoo/songfetch +brew install songfetch +``` + ## Dependencies: ```bash python diff --git a/pyproject.toml b/pyproject.toml index b0ae020..4064ef4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", "Operating System :: POSIX :: Linux", + "Operating System :: MacOS", ] [project.scripts] diff --git a/src/songfetch/__init__.py b/src/songfetch/__init__.py index e69de29..e00677a 100644 --- a/src/songfetch/__init__.py +++ b/src/songfetch/__init__.py @@ -0,0 +1,10 @@ +import platform + +if platform.system() == 'Darwin': + from .player_utils_mac import * +elif platform.system() == 'Linux': + from .player_utils_linux import * +else: + # Fallback to Linux for other systems + from .player_utils_linux import * + diff --git a/src/songfetch/ascii_convert.py b/src/songfetch/ascii_convert.py index a8788a8..59dcb31 100644 --- a/src/songfetch/ascii_convert.py +++ b/src/songfetch/ascii_convert.py @@ -1,17 +1,25 @@ -import ascii_magic as magic, urllib.parse, urllib.request, tempfile +import ascii_magic as magic +import urllib.parse +import urllib.request +import tempfile from importlib import resources + def default_art(file="../assets/default_art.txt"): # Get the default music note art from file - with resources.files("songfetch.assets").joinpath("default_art.txt").open("r", encoding="utf-8") as f: + with resources.files("songfetch.assets").joinpath( + "default_art.txt" + ).open("r", encoding="utf-8") as f: return f.read().split('\n') # Art to ASCII + + def convert(art_uri): # Init variables ascii_art_lines = None # Check file type - if art_uri is None or art_uri.strip() == "": # If return empty string + if art_uri is None or art_uri.strip() == "": # If return empty string return default_art() # We need to check each URI type @@ -22,7 +30,7 @@ def convert(art_uri): # Decode to get rid of possible "%20%20..." ascii_art = magic.from_image(urllib.parse.unquote(new_uri)) # Convert to ascii - ascii_string = ascii_art.to_ascii(columns = 60, width_ratio = 2.2) + ascii_string = ascii_art.to_ascii(columns=60, width_ratio=2.2) ascii_art_lines = ascii_string.split('\n') # Catch the error @@ -39,11 +47,11 @@ def convert(art_uri): urllib.request.urlretrieve(art_uri, temp.name) ascii_art = magic.from_image(temp.name) # Convert to ascii - ascii_string = ascii_art.to_ascii(columns = 60, width_ratio = 2.2) + ascii_string = ascii_art.to_ascii(columns=60, width_ratio=2.2) ascii_art_lines = ascii_string.split('\n') # Catch the error - except Exception as e : + except Exception as e: return default_art() # Edge cases @@ -55,4 +63,3 @@ def convert(art_uri): return ascii_art_lines else: return default_art() - diff --git a/src/songfetch/assets/__init__.py b/src/songfetch/assets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/songfetch/main.py b/src/songfetch/main.py index 0c8c5b0..2a59234 100755 --- a/src/songfetch/main.py +++ b/src/songfetch/main.py @@ -1,6 +1,6 @@ import os from songfetch.ascii_convert import convert -from songfetch.player_utils import ( +from songfetch import ( get_art, get_loop, get_shuffle, @@ -18,6 +18,7 @@ get_position ) + def progress_bar(): # Calculate percentage pos = get_position() @@ -46,6 +47,7 @@ def progress_bar(): return fprint + eprint + display_str + def get_info_line(): # Get some strings to use later line = f"\033[34m─────────────────────────────────────────\033[0m" @@ -65,34 +67,36 @@ def get_info_line(): # Here we concatenate all the info into one list # Use ANSI escape sequences to style the colors info_lines = [ - # Print username and track data - f"\033[1;34m{get_user()}\033[0m@\033[1;34m{get_player_name()}\033[0m", - line, f"\033[97m{now_playing}\033[0m", line, - f"\033[34mTitle\033[0m: {get_title()}", - f"\033[34mArtist\033[0m: {get_artist()}", - f"\033[34mAlbum\033[0m: {get_album()}", - f"\033[34mDuration\033[0m: {get_duration_formatted()}", - f"\033[34m{progress_bar()}\033[0m", - - # Print player data - line, f"\033[97m{playback_info}\033[0m", line, - f"\033[34mStatus\033[0m: {get_status()}", - f"\033[34mVolume\033[0m: {get_volume()}", - f"\033[34mLoop\033[0m: {get_loop()}", - f"\033[34mShuffle\033[0m: {get_shuffle()}", - f"\033[34mPlayer\033[0m: {get_player_name()}", - f"\033[34mURL\033[0m: {get_url()}", - - # Print system data - line, f"\033[97m{audio_system}\033[0m", line, - f"\033[34mBackend\033[0m: {get_backend()}", - "", - # Print palette - normal, bright -] + # Print username and track data + f"\033[1;34m{get_user()}\033[0m@\033[1;34m{get_player_name()}\033[0m", + line, f"\033[97m{now_playing}\033[0m", line, + f"\033[34mTitle\033[0m: {get_title()}", + f"\033[34mArtist\033[0m: {get_artist()}", + f"\033[34mAlbum\033[0m: {get_album()}", + f"\033[34mDuration\033[0m: {get_duration_formatted()}", + f"\033[34m{progress_bar()}\033[0m", + + # Print player data + line, f"\033[97m{playback_info}\033[0m", line, + f"\033[34mStatus\033[0m: {get_status()}", + f"\033[34mVolume\033[0m: {get_volume()}", + f"\033[34mLoop\033[0m: {get_loop()}", + f"\033[34mShuffle\033[0m: {get_shuffle()}", + f"\033[34mPlayer\033[0m: {get_player_name()}", + f"\033[34mURL\033[0m: {get_url()}", + + # Print system data + line, f"\033[97m{audio_system}\033[0m", line, + f"\033[34mBackend\033[0m: {get_backend()}", + "", + # Print palette + normal, bright + ] return info_lines # Main function + + def main(): # Get terminal size columns = os.get_terminal_size().columns @@ -107,25 +111,36 @@ def main(): else: art_col = convert(get_art()) # Find the longest line in both column lists - max_art = max(len(x) for x in art_col) + if art_col: + max_art = max(len(x) for x in art_col) + else: + max_art = 0 # Always get info lines though info_col = get_info_line() - max_info = max(len(y) for y in info_col[:-2]) + if len(info_col) > 2: + max_info = max(len(y) for y in info_col[:-2]) + else: + max_info = 0 + + # Ensure minimum widths, caused crashes by empty art/info + art_width = max(1, max_art - 2) + info_width = max(1, max_info) - # Print lists next to each other when one list may be longer than the other: + # Print lists next to each other when one may be longer if len(art_col) > len(info_col): # Compare lenghts # Replace but with added padding (spaces) new_info_col = info_col + [''] * (len(art_col) - len(info_col)) # Loop through all lines in art for i in range(len(art_col)): - print(f"{art_col[i]:{max_art-2}}{new_info_col[i]:{max_info}}") + print(f"{art_col[i]:{art_width}}{new_info_col[i]:{info_width}}") else: # Pad but the othre way around new_art_col = art_col + [''] * (len(info_col) - len(art_col)) # Loop all lines in info for j in range(len(info_col)): - print(f"{new_art_col[j]:{max_art-2}}{info_col[j]:{max_info}}") + print(f"{new_art_col[j]:{art_width}}{info_col[j]:{info_width}}") + # Run the program if __name__ == "__main__": diff --git a/src/songfetch/player_utils.py b/src/songfetch/player_utils_linux.py similarity index 100% rename from src/songfetch/player_utils.py rename to src/songfetch/player_utils_linux.py diff --git a/src/songfetch/player_utils_mac.py b/src/songfetch/player_utils_mac.py new file mode 100644 index 0000000..14e6afd --- /dev/null +++ b/src/songfetch/player_utils_mac.py @@ -0,0 +1,376 @@ +import subprocess +import getpass + +# Default values for fallback +DEFAULTS = { + "player_name": "No player", + "art": "", + "title": "No title found", + "artist": "No artist found", + "album": "No album found", + "duration_formatted": "00:00", + "volume": "Unknown", + "position": 0, + "duration": 0, + "url": "No URL found", + "status": "No status", + "loop": "Unknown", + "shuffle": "Unknown", + "user": "No User" +} + +# Supported players and their AppleScript apps +SUPPORTED_PLAYERS = { + "Spotify": "Spotify", + "Music": "Music", + "iTunes": "iTunes", # For older macOS +} + +_current_player = None + + +def run_applescript(script): + """Helper to run AppleScript and return output""" + try: + result = subprocess.run([ + "osascript", "-e", script + ], capture_output=True, text=True) + return result.stdout.strip() + except Exception: + return "" + + +def detect_current_player(): + """Detect which media player is currently active/playing""" + global _current_player + + for player_name, app_name in SUPPORTED_PLAYERS.items(): + script = f''' + tell application "System Events" + if exists (processes where name is "{app_name}") then + tell application "{app_name}" + if player state is playing or player state is paused then + return "{player_name}" + end if + end tell + end if + end tell + return "" + ''' + result = run_applescript(script) + if result: + _current_player = result + return result + + _current_player = None + return None + + +def get_current_player(): + """Get the current player, detecting if not set""" + if _current_player is None: + detect_current_player() + return _current_player + + +def get_player_name(): + player = get_current_player() + if player: + return player + return DEFAULTS["player_name"] + + +def get_art(): + # Album art is not easily accessible via AppleScript for most players + return DEFAULTS["art"] + + +def get_title(): + player = get_current_player() + if not player: + return DEFAULTS["title"] + + app_name = SUPPORTED_PLAYERS.get(player, "Music") + + if player == "Spotify": + script = ''' + tell application "Spotify" + if player state is playing or player state is paused then + return name of current track + end if + end tell + ''' + else: # Music or iTunes + script = f''' + tell application "{app_name}" + if player state is playing or player state is paused then + return name of current track + end if + end tell + ''' + + title = run_applescript(script) + return title if title else DEFAULTS["title"] + + +def get_artist(): + player = get_current_player() + if not player: + return DEFAULTS["artist"] + + app_name = SUPPORTED_PLAYERS.get(player, "Music") + + if player == "Spotify": + script = ''' + tell application "Spotify" + if player state is playing or player state is paused then + return artist of current track + end if + end tell + ''' + else: + script = f''' + tell application "{app_name}" + if player state is playing or player state is paused then + return artist of current track + end if + end tell + ''' + + artist = run_applescript(script) + return artist if artist else DEFAULTS["artist"] + + +def get_album(): + player = get_current_player() + if not player: + return DEFAULTS["album"] + + app_name = SUPPORTED_PLAYERS.get(player, "Music") + + if player == "Spotify": + script = ''' + tell application "Spotify" + if player state is playing or player state is paused then + return album of current track + end if + end tell + ''' + else: + script = f''' + tell application "{app_name}" + if player state is playing or player state is paused then + return album of current track + end if + end tell + ''' + + album = run_applescript(script) + return album if album else DEFAULTS["album"] + + +def get_duration_formatted(): + player = get_current_player() + if not player: + return DEFAULTS["duration_formatted"] + + app_name = SUPPORTED_PLAYERS.get(player, "Music") + + if player == "Spotify": + script = ''' + tell application "Spotify" + if player state is playing or player state is paused then + set dur to duration of current track + set mins to dur div 60 + set secs to dur mod 60 + return (mins as string) & ":" & text -2 thru -1 of ("0" & secs) + end if + end tell + ''' + else: + script = f''' + tell application "{app_name}" + if player state is playing or player state is paused then + set dur to duration of current track + set mins to dur div 60 + set secs to dur mod 60 + return (mins as string) & ":" & text -2 thru -1 of ("0" & secs) + end if + end tell + ''' + + duration = run_applescript(script) + return duration if duration else DEFAULTS["duration_formatted"] + + +def get_volume(): + try: + script = ''' + set vol to output volume of (get volume settings) + return vol as string + ''' + vol = run_applescript(script) + if vol.isdigit(): + return f"{vol}%" + return DEFAULTS["volume"] + except Exception: + return DEFAULTS["volume"] + + +def get_position(): + player = get_current_player() + if not player: + return DEFAULTS["position"] + + app_name = SUPPORTED_PLAYERS.get(player, "Music") + + script = f''' + tell application "{app_name}" + if player state is playing or player state is paused then + return player position + end if + end tell + ''' + + pos = run_applescript(script) + try: + return int(float(pos)) + except ValueError: + return DEFAULTS["position"] + + +def get_duration(): + player = get_current_player() + if not player: + return DEFAULTS["duration"] + + app_name = SUPPORTED_PLAYERS.get(player, "Music") + + if player == "Spotify": + script = ''' + tell application "Spotify" + if player state is playing or player state is paused then + return duration of current track + end if + end tell + ''' + else: + script = f''' + tell application "{app_name}" + if player state is playing or player state is paused then + return duration of current track + end if + end tell + ''' + + dur = run_applescript(script) + try: + return int(float(dur)) + except ValueError: + return DEFAULTS["duration"] + + +def get_url(): + # URLs are not typically available for local tracks + return DEFAULTS["url"] + + +def get_status(): + player = get_current_player() + if not player: + return DEFAULTS["status"] + + app_name = SUPPORTED_PLAYERS.get(player, "Music") + + script = f''' + tell application "{app_name}" + set state to player state + if state is playing then + return "Playing" + else if state is paused then + return "Paused" + else + return "Stopped" + end if + end tell + ''' + + status = run_applescript(script) + return status if status else DEFAULTS["status"] + + +def get_loop(): + player = get_current_player() + if not player: + return DEFAULTS["loop"] + + app_name = SUPPORTED_PLAYERS.get(player, "Music") + + if player == "Spotify": + # Spotify doesn't have loop in AppleScript, return Unknown + return DEFAULTS["loop"] + else: + script = f''' + tell application "{app_name}" + if player state is playing or player state is paused then + if repeat is on then + return "On" + else + return "Off" + end if + end if + end tell + ''' + + loop = run_applescript(script) + return loop if loop else DEFAULTS["loop"] + + +def get_shuffle(): + player = get_current_player() + if not player: + return DEFAULTS["shuffle"] + + app_name = SUPPORTED_PLAYERS.get(player, "Music") + + if player == "Spotify": + # Spotify shuffle status via AppleScript + script = ''' + tell application "Spotify" + if player state is playing or player state is paused then + if shuffling is true then + return "On" + else + return "Off" + end if + end if + end tell + ''' + else: + script = f''' + tell application "{app_name}" + if player state is playing or player state is paused then + if shuffle is on then + return "On" + else + return "Off" + end if + end if + end tell + ''' + + shuffle = run_applescript(script) + return shuffle if shuffle else DEFAULTS["shuffle"] + + +def get_user(): + try: + username = getpass.getuser() + return username if username else DEFAULTS["user"] + except Exception: + return DEFAULTS["user"] + + +# Backend is CoreAudio +def get_backend(): + return "CoreAudio"