refactor modules

This commit is contained in:
2026-03-29 17:10:57 +02:00
parent 0b0dd1a1ba
commit a13add9b81
31 changed files with 33 additions and 0 deletions

View File

@@ -0,0 +1 @@
use flake ../../#spotify-shortcuts --show-trace

View File

@@ -0,0 +1,9 @@
{python3Packages}:
with python3Packages;
buildPythonApplication {
name = "spotify-shortcuts";
propagatedBuildInputs = [spotipy pyxdg desktop-notifier];
pyproject = true;
build-system = [setuptools];
src = ./.;
}

View File

@@ -0,0 +1,15 @@
{pkgs ? import <nixpkgs> {}}: let
drv = pkgs.callPackage ./derivation.nix {};
in
pkgs.mkShell {
packages = [
pkgs.pyright
pkgs.black
];
inputsFrom = [drv];
shellHook = ''
export PYTHONPATH="$PYTHONPATH:$(pwd)"
'';
}

View File

@@ -0,0 +1,56 @@
{self, ...}: {
flake.overlays.spotify-shortcuts = final: prev: {
spotify-shortcuts = final.callPackage ./_derivation.nix {};
};
perSystem = {pkgs, ...}: {
packages.spotify-shortcuts = pkgs.callPackage ./_derivation.nix {};
devShells.spotify-shortcuts = import ./_shell.nix {inherit pkgs;};
};
flake.nixosModules.spotify-shortcuts-overlay = {
nixpkgs.overlays = [
self.overlays.spotify-shortcuts
];
};
flake.nixosModules.spotify-shortcuts = {
config,
lib,
pkgs,
...
}: let
cfg = config.hive.programs.spotify-shortcuts;
in {
options.hive.programs.spotify-shortcuts = {
enable = lib.mkEnableOption "Enable Spotify Shortcuts";
clientIdSopsKey = lib.mkOption {
type = lib.types.singleLineStr;
description = "Spotify API Client ID sops secret name";
};
clientSecretSopsKey = lib.mkOption {
type = lib.types.singleLineStr;
description = "Spotify API Client Secret Path sops secret name";
};
};
imports = [
self.nixosModules.spotify-shortcuts-overlay
];
config = lib.mkIf cfg.enable {
environment.systemPackages = [pkgs.spotify-shortcuts];
environment.variables = {
SPOTIFY_SHORTCUTS_CONFIG = config.sops.templates."spotify-shortcuts-client.json".path;
};
sops.templates."spotify-shortcuts-client.json" = {
mode = "444";
content = ''
{
"clientId": "${config.sops.placeholder.${cfg.clientIdSopsKey}}",
"clientSecret": "${config.sops.placeholder.${cfg.clientSecretSopsKey}}"
}
'';
};
};
};
}

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env python
from setuptools import setup, find_packages
setup(
name="spotify_shortcuts",
version="1.0",
packages=find_packages(),
entry_points={
"console_scripts": [
"spotisc=spotify_shortcuts.run:main",
"spotify-like=spotify_shortcuts.spotify_like:main",
"spotify-pl-add=spotify_shortcuts.spotify_pl_add:main",
],
},
)

View File

@@ -0,0 +1,4 @@
from spotify_shortcuts.run import main
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,74 @@
from pathlib import Path
from dataclasses import dataclass
from argparse import ArgumentParser
from typing import List
from os import getenv
from sys import argv
from xdg.BaseDirectory import xdg_cache_home
from json import loads
@dataclass
class Config:
cache_file: Path = Path(xdg_cache_home) / Path("spotify-shortcuts.json")
client_id: str | None = None
client_secret: str | None = None
notifications: bool = True
@staticmethod
def from_file(path: Path):
if not path.exists():
raise FileNotFoundError(f"Configuration file {path} does not exist.")
with open(path, "r") as f:
data = loads(f.read())
return Config(
cache_file=Path(data.get("cacheFile", Config.cache_file)),
client_id=data.get("clientId", Config.client_id),
client_secret=data.get("clientSecret", Config.client_secret),
)
def load_config(args: List[str] = argv[1:]) -> Config:
parser = ArgumentParser(description="Spotify CLI Tool")
parser.add_argument(
"--cache-file",
type=Path,
default=Config.cache_file,
help="Path to the cache file for Spotify authentication",
)
parser.add_argument(
"--client-id",
type=str,
default=Config.client_id,
help="Spotify API Client ID",
)
parser.add_argument(
"--client-secret",
type=str,
default=Config.client_secret,
help="Spotify API Client Secret",
)
parser.add_argument(
"--config-file",
type=str,
help="Path to a json configuration file with keys clientId and clientSecret",
)
parser.add_argument(
"--no-notifications",
action="store_true",
help="Disable desktop notifications",
)
ns = parser.parse_args(args)
cfg = Config()
if (cfg_file := ns.config_file or getenv("SPOTIFY_SHORTCUTS_CONFIG", None)) != None:
cfg = Config.from_file(Path(cfg_file))
return Config(
cache_file=ns.cache_file or cfg.cache_file,
client_id=ns.client_id or cfg.client_id,
client_secret=ns.client_secret or cfg.client_secret,
notifications=not ns.no_notifications or cfg.notifications,
)

View File

@@ -0,0 +1,7 @@
from spotify_shortcuts.spotify_like import SpotifyLike
from spotify_shortcuts.spotify_pl_add import SpotifyPlAdd
SHORTCUT_REGISTRY = {
"like": SpotifyLike(),
"pl_add": SpotifyPlAdd(),
}

View File

@@ -0,0 +1,47 @@
from spotify_shortcuts.config import Config, load_config
from spotify_shortcuts.shortcut import Shortcut
from spotify_shortcuts.spotify_auth import authenticated_session
from itertools import chain
def all_scopes() -> list[str]:
from spotify_shortcuts.registry import SHORTCUT_REGISTRY
return list(
set(
chain.from_iterable(
shortcut.get_scopes() for shortcut in SHORTCUT_REGISTRY.values()
)
)
)
def run_shortcut(shortcut: Shortcut, config: Config):
client = authenticated_session(
config, scopes=all_scopes() # use all scopes to avoid re-authentication
)
shortcut.execute(client, config)
def main():
from spotify_shortcuts.registry import SHORTCUT_REGISTRY
import sys
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <shortcut_name>")
sys.exit(1)
shortcut_name = sys.argv[1]
if shortcut_name not in SHORTCUT_REGISTRY:
print(f"Shortcut '{shortcut_name}' not found.")
sys.exit(1)
shortcut = SHORTCUT_REGISTRY[shortcut_name]
config = load_config(args=sys.argv[2:])
run_shortcut(shortcut, config)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,21 @@
from abc import ABC, abstractmethod
from spotify_shortcuts.config import Config
from spotipy import Spotify
class Shortcut(ABC):
@abstractmethod
def execute(self, client: Spotify, config: Config):
"""Execute the shortcut action."""
pass
@abstractmethod
def get_help(self) -> str:
"""Return a description of the shortcut."""
pass
@abstractmethod
def get_scopes(self) -> list[str]:
"""Return the spotify API scopes required for the shortcut."""
pass

View File

@@ -0,0 +1,20 @@
from spotipy.oauth2 import SpotifyOAuth
from spotipy import Spotify
from spotify_shortcuts.config import Config
CALLBACK_URI = "http://127.0.0.1:45632/callback"
def authenticated_session(cfg: Config, scopes: list[str]) -> Spotify:
assert cfg.client_id, "Spotify client ID is required"
assert cfg.client_secret, "Spotify client secret is required"
return Spotify(
auth_manager=SpotifyOAuth(
client_id=cfg.client_id,
client_secret=cfg.client_secret,
redirect_uri=CALLBACK_URI,
scope=" ".join(scopes),
cache_path=cfg.cache_file,
)
)

View File

@@ -0,0 +1,46 @@
from spotify_shortcuts.run import run_shortcut
from spotify_shortcuts.shortcut import Shortcut
from spotify_shortcuts.config import load_config
from desktop_notifier import DesktopNotifierSync
SCOPES = [
"user-library-read",
"user-library-modify",
"user-read-playback-state",
]
class SpotifyLike(Shortcut):
def execute(self, client, config):
if (playback := client.current_playback()) is None:
print("No current playback found.")
return
if (uri := playback.get("item", {}).get("uri", None)) is None:
print("No track URI found in current playback.")
return
client.current_user_saved_tracks_add(tracks=[uri])
if config.notifications:
dn = DesktopNotifierSync()
dn.send(
title="Track Liked",
message=f"Track \"{playback.get('item', {}).get('name', '<no-name>')}\" by \"{
", ".join(a.get('name', '<no-name>') for a in playback.get('item', {}).get('artists', []))
}\" has been liked.",
)
def get_help(self) -> str:
return "Like the currently playing track."
def get_scopes(self) -> list[str]:
return SCOPES
def main():
run_shortcut(SpotifyLike(), load_config())
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,57 @@
from spotify_shortcuts.shortcut import Shortcut
from spotify_shortcuts.config import load_config
from desktop_notifier import DesktopNotifierSync
from spotify_shortcuts.run import run_shortcut
SCOPES = [
"playlist-modify-public",
"playlist-modify-private",
"user-read-playback-state",
]
class SpotifyPlAdd(Shortcut):
def execute(self, client, config):
if (playback := client.current_playback()) is None:
print("No current playback found.")
return
if (track_uri := playback.get("item", {}).get("uri", None)) is None:
print("No track URI found in current playback.")
return
if (context_uri := playback.get("context", {}).get("uri", None)) is None:
print("No context URI found in current playback.")
return
client.playlist_add_items(context_uri, items=[track_uri])
if config.notifications:
track_name = playback.get("item", {}).get("name", "<no-name>")
artists = ", ".join(
a.get("name", "<no-name>")
for a in playback.get("item", {}).get("artists", [])
)
playlist_name = (client.playlist(context_uri) or {}).get(
"name", "<no-name>"
)
dn = DesktopNotifierSync()
dn.send(
title="Track Added to Playlist",
message=f'Track "{track_name}" by "{artists}" has been added to {playlist_name}.',
)
def get_help(self) -> str:
return "Add the currently playing track to the current playlist."
def get_scopes(self) -> list[str]:
return SCOPES
def main():
run_shortcut(SpotifyPlAdd(), load_config())
if __name__ == "__main__":
main()