This commit has no recorded session.
diff
commit ab6459e4b049e1e0e6abc4a2e3889b11cc0fd2eaAuthor: Colin Powell <colin@unbl.ink>Date: Sun Jun 21 01:26:16 2026 -0400 [mcp] Add a basic mcp service@@ -9,7 +9,7 @@ python = ">=3.11,<3.15" Django = "^4.0.3" django-extensions = "^3.1.5" python-dateutil = "^2.8.2"-python-dotenv = "^0.20.0"+python-dotenv = ">=0.20.0,<2" python-json-logger = "^2.0.2" colorlog = "^6.6.0" httpx = "<=0.27.2"@@ -43,6 +43,7 @@ ipython = "^8.14.0" pendulum = "^3" trafilatura = "^1.6.3" django-imagekit = "^5.0.0"+django-mcp-server = "^0.5.7" thefuzz = "^0.22.1" dataclass-wizard = "^0.35.0" webdavclient3 = "^3.14.6"@@ -0,0 +1,518 @@+from mcp_server import MCPToolset+from scrobbles.models import Scrobble+from scrobbles.constants import LONG_PLAY_MEDIA+++class ScrobbleToolset(MCPToolset):+ def list_recent_scrobbles(+ self,+ days: int = 7,+ media_type: str | None = None,+ limit: int = 50,+ ) -> list[dict]:+ """List scrobbles from the last N days, optionally filtered by media type.+ Valid media_type values: Video, Track, PodcastEpisode, SportEvent, Book,+ Paper, VideoGame, BoardGame, GeoLocation, Trail, Beer, Puzzle, Food, Task,+ WebPage, LifeEvent, Mood, BrickSet, Channel, BirdingLocation, DiscGolfCourse+ """+ qs = (+ Scrobble.objects.filter(user=self.request.user)+ .select_related(+ "video", "track", "book", "video_game", "board_game",+ "beer", "puzzle", "food", "trail", "task", "web_page",+ "life_event", "mood", "brick_set", "podcast_episode",+ "sport_event", "geo_location", "birding_location",+ "disc_golf_course", "channel",+ )+ .order_by("-timestamp")+ )+ from django.utils import timezone+ import datetime+ qs = qs.filter(timestamp__gte=timezone.now() - datetime.timedelta(days=days))+ if media_type:+ qs = qs.filter(media_type=media_type)+ qs = qs[:limit]+ return [_scrobble_to_dict(s) for s in qs]++ def get_scrobble(self, uuid: str) -> dict | None:+ """Get a single scrobble by its UUID."""+ try:+ s = Scrobble.objects.filter(user=self.request.user).get(uuid=uuid)+ except Scrobble.DoesNotExist:+ return None+ return _scrobble_to_dict(s)++ def search_scrobbles(+ self, query: str, media_type: str | None = None, limit: int = 20+ ) -> list[dict]:+ """Search scrobbles by text in their log data or related media titles."""+ from django.db.models import Q+ qs = Scrobble.objects.filter(user=self.request.user).order_by("-timestamp")+ if media_type:+ qs = qs.filter(media_type=media_type)+ qs = qs.filter(+ Q(log__icontains=query)+ | Q(video__title__icontains=query)+ | Q(track__title__icontains=query)+ | Q(book__title__icontains=query)+ | Q(video_game__title__icontains=query)+ | Q(board_game__title__icontains=query)+ | Q(beer__title__icontains=query)+ | Q(food__title__icontains=query)+ | Q(trail__title__icontains=query)+ | Q(task__title__icontains=query)+ | Q(web_page__title__icontains=query)+ | Q(life_event__title__icontains=query)+ | Q(puzzle__title__icontains=query)+ | Q(brick_set__title__icontains=query)+ | Q(podcast_episode__title__icontains=query)+ )[:limit]+ return [_scrobble_to_dict(s) for s in qs]++ def get_scrobbles_by_date(+ self, date: str, media_type: str | None = None+ ) -> list[dict]:+ """Get scrobbles for a specific date (YYYY-MM-DD format)."""+ import datetime+ try:+ dt = datetime.datetime.strptime(date, "%Y-%m-%d").date()+ except ValueError:+ return []+ qs = Scrobble.objects.filter(+ user=self.request.user,+ timestamp__date=dt,+ ).order_by("-timestamp")+ if media_type:+ qs = qs.filter(media_type=media_type)+ return [_scrobble_to_dict(s) for s in qs]++ def get_in_progress_scrobbles(+ self, media_type: str | None = None+ ) -> list[dict]:+ """Get scrobbles currently in progress (started but not finished).+ These are long-play items like books, video games, brick sets, or tasks."""+ qs = Scrobble.objects.filter(+ user=self.request.user,+ in_progress=True,+ ).order_by("-timestamp")+ if media_type:+ qs = qs.filter(media_type=media_type)+ return [_scrobble_to_dict(s) for s in qs]++ def get_long_play_scrobbles(+ self, status: str = "in_progress", media_type: str | None = None+ ) -> list[dict]:+ """Get long-play scrobbles (books, video games, brick sets, tasks).+ Status can be 'in_progress' or 'completed'."""+ types = list(LONG_PLAY_MEDIA.values())+ qs = Scrobble.objects.filter(+ user=self.request.user,+ media_type__in=types,+ ).order_by("-timestamp")+ if media_type:+ qs = qs.filter(media_type=media_type)+ if status == "in_progress":+ qs = qs.filter(in_progress=True)+ elif status == "completed":+ qs = qs.filter(in_progress=False)+ return [_scrobble_to_dict(s) for s in qs]+++class MediaToolset(MCPToolset):+ def get_book(self, uuid: str) -> dict | None:+ """Get a book by UUID."""+ from books.models import Book+ try:+ b = Book.objects.get(uuid=uuid)+ except Book.DoesNotExist:+ return None+ return _media_to_dict(b, fields=["title", "pages", "language",+ "first_publish_year", "isbn_13",+ "publisher", "summary"])++ def list_books(self, author: str | None = None, limit: int = 20) -> list[dict]:+ """List books, optionally filtered by author name."""+ from books.models import Book+ qs = Book.objects.all().order_by("title")+ if author:+ qs = qs.filter(authors__name__icontains=author)+ return [_media_to_dict(b, fields=["title", "pages", "language",+ "first_publish_year", "isbn_13",+ "publisher"]) for b in qs[:limit]]++ def get_track(self, uuid: str) -> dict | None:+ """Get a music track by UUID."""+ from music.models import Track+ try:+ t = Track.objects.select_related("artist_fk").get(uuid=uuid)+ except Track.DoesNotExist:+ return None+ return _media_to_dict(t, fields=["title", "base_run_time_seconds",+ "artist_fk__name", "genre"])++ def list_tracks(self, artist: str | None = None, limit: int = 20) -> list[dict]:+ """List music tracks, optionally filtered by artist name."""+ from music.models import Track+ qs = Track.objects.select_related("artist_fk").all().order_by("title")+ if artist:+ qs = qs.filter(artist_fk__name__icontains=artist)+ return [_media_to_dict(t, fields=["title", "base_run_time_seconds",+ "artist_fk__name", "genre"])+ for t in qs[:limit]]++ def get_video(self, uuid: str) -> dict | None:+ """Get a video by UUID."""+ from videos.models import Video+ try:+ v = Video.objects.select_related("tv_series", "channel").get(uuid=uuid)+ except Video.DoesNotExist:+ return None+ return _media_to_dict(v, fields=["title", "year", "overview",+ "imdb_id", "imdb_rating",+ "tv_series__name", "channel__title",+ "season_number", "episode_number"])++ def list_videos(self, series: str | None = None, limit: int = 20) -> list[dict]:+ """List videos, optionally filtered by series name."""+ from videos.models import Video+ qs = Video.objects.select_related("tv_series", "channel").all().order_by("title")+ if series:+ qs = qs.filter(tv_series__name__icontains=series)+ return [_media_to_dict(v, fields=["title", "year", "overview",+ "tv_series__name", "channel__title",+ "season_number", "episode_number"])+ for v in qs[:limit]]++ def get_board_game(self, uuid: str) -> dict | None:+ """Get a board game by UUID."""+ from boardgames.models import BoardGame+ try:+ bg = BoardGame.objects.get(uuid=uuid)+ except BoardGame.DoesNotExist:+ return None+ return _media_to_dict(bg, fields=["title", "genre"])++ def list_board_games(self, limit: int = 20) -> list[dict]:+ """List board games."""+ from boardgames.models import BoardGame+ qs = BoardGame.objects.all().order_by("title")[:limit]+ return [_media_to_dict(bg, fields=["title", "genre"]) for bg in qs]++ def get_podcast_episode(self, uuid: str) -> dict | None:+ """Get a podcast episode by UUID."""+ from podcasts.models import PodcastEpisode+ try:+ pe = PodcastEpisode.objects.select_related("podcast", "producer").get(+ uuid=uuid+ )+ except PodcastEpisode.DoesNotExist:+ return None+ return _media_to_dict(pe, fields=["title", "podcast__title",+ "producer__name", "base_run_time_seconds"])++ def get_beer(self, uuid: str) -> dict | None:+ """Get a beer by UUID."""+ from beers.models import Beer+ try:+ b = Beer.objects.select_related("style", "producer").get(uuid=uuid)+ except Beer.DoesNotExist:+ return None+ return _media_to_dict(b, fields=["title", "style__name",+ "producer__name", "abv"])++ def get_brick_set(self, uuid: str) -> dict | None:+ """Get a brick set (LEGO) by UUID."""+ from bricksets.models import BrickSet+ try:+ bs = BrickSet.objects.get(uuid=uuid)+ except BrickSet.DoesNotExist:+ return None+ return _media_to_dict(bs, fields=["title", "piece_count", "set_number"])++ def get_video_game(self, uuid: str) -> dict | None:+ """Get a video game by UUID."""+ from videogames.models import VideoGame+ try:+ vg = VideoGame.objects.get(uuid=uuid)+ except VideoGame.DoesNotExist:+ return None+ return _media_to_dict(vg, fields=["title", "genre",+ "base_run_time_seconds"])++ def get_puzzle(self, uuid: str) -> dict | None:+ """Get a puzzle by UUID."""+ from puzzles.models import Puzzle+ try:+ p = Puzzle.objects.select_related("manufacturer").get(uuid=uuid)+ except Puzzle.DoesNotExist:+ return None+ return _media_to_dict(p, fields=["title", "piece_count",+ "manufacturer__name"])++ def get_web_page(self, uuid: str) -> dict | None:+ """Get a web page by UUID."""+ from webpages.models import WebPage+ try:+ wp = WebPage.objects.select_related("domain").get(uuid=uuid)+ except WebPage.DoesNotExist:+ return None+ return _media_to_dict(wp, fields=["title", "url", "domain__name"])++ def get_task(self, uuid: str) -> dict | None:+ """Get a task by UUID."""+ from tasks.models import Task+ try:+ t = Task.objects.get(uuid=uuid)+ except Task.DoesNotExist:+ return None+ return _media_to_dict(t, fields=["title", "completed"])++ def get_trail(self, uuid: str) -> dict | None:+ """Get a trail by UUID."""+ from trails.models import Trail+ try:+ t = Trail.objects.get(uuid=uuid)+ except Trail.DoesNotExist:+ return None+ return _media_to_dict(t, fields=["title", "genre", "base_run_time_seconds"])++ def get_geo_location(self, uuid: str) -> dict | None:+ """Get a geo location by UUID."""+ from locations.models import GeoLocation+ try:+ gl = GeoLocation.objects.get(uuid=uuid)+ except GeoLocation.DoesNotExist:+ return None+ return _media_to_dict(gl, fields=["title", "latitude", "longitude"])++ def get_life_event(self, uuid: str) -> dict | None:+ """Get a life event by UUID."""+ from lifeevents.models import LifeEvent+ try:+ le = LifeEvent.objects.get(uuid=uuid)+ except LifeEvent.DoesNotExist:+ return None+ return _media_to_dict(le, fields=["title", "event_date", "genre"])++ def get_mood(self, uuid: str) -> dict | None:+ """Get a mood entry by UUID."""+ from moods.models import Mood+ try:+ m = Mood.objects.get(uuid=uuid)+ except Mood.DoesNotExist:+ return None+ return _media_to_dict(m, fields=["title", "mood_type", "mood_quality"])++ def get_food(self, uuid: str) -> dict | None:+ """Get a food entry by UUID."""+ from foods.models import Food+ try:+ f = Food.objects.select_related("category").get(uuid=uuid)+ except Food.DoesNotExist:+ return None+ return _media_to_dict(f, fields=["title", "category__name"])++ def get_bird_sighting(self, uuid: str) -> dict | None:+ """Get a bird sighting by UUID."""+ from birds.models import BirdSighting+ try:+ bs = BirdSighting.objects.select_related("bird").get(uuid=uuid)+ except BirdSighting.DoesNotExist:+ return None+ return _media_to_dict(bs, fields=["title", "bird__common_name",+ "bird__scientific_name", "location"])++ def get_disc_golf_course(self, uuid: str) -> dict | None:+ """Get a disc golf course by UUID."""+ from discgolf.models import DiscGolfCourse+ try:+ dg = DiscGolfCourse.objects.get(uuid=uuid)+ except DiscGolfCourse.DoesNotExist:+ return None+ return _media_to_dict(dg, fields=["title", "holes", "location"])+++class StatsToolset(MCPToolset):+ def get_scrobble_counts(self, days: int = 30) -> list[dict]:+ """Get scrobble counts grouped by media type for the last N days."""+ from django.utils import timezone+ import datetime+ from django.db.models import Count++ cutoff = timezone.now() - datetime.timedelta(days=days)+ qs = (+ Scrobble.objects.filter(user=self.request.user, timestamp__gte=cutoff)+ .values("media_type")+ .annotate(count=Count("id"))+ .order_by("-count")+ )+ return list(qs)++ def get_top_media(+ self, media_type: str, days: int = 30, limit: int = 10+ ) -> list[dict]:+ """Get the most-scrobbled items of a given media type in the last N days.+ Valid media_type values: Video, Track, Book, BoardGame, Beer, etc."""+ from django.utils import timezone+ import datetime+ from django.db.models import Count++ cutoff = timezone.now() - datetime.timedelta(days=days)++ rel_field = _media_type_to_rel_field(media_type)+ if not rel_field:+ return []++ qs = (+ Scrobble.objects.filter(+ user=self.request.user,+ media_type=media_type,+ timestamp__gte=cutoff,+ )+ .values(rel_field)+ .annotate(count=Count("id"))+ .order_by("-count")+ )[:limit]++ results = []+ for row in qs:+ obj_id = row[rel_field]+ if obj_id is None:+ continue+ results.append({"id": obj_id, "count": row["count"]})+ return results+++def _media_type_to_rel_field(media_type: str) -> str | None:+ mapping = {+ "Video": "video",+ "Track": "track",+ "PodcastEpisode": "podcast_episode",+ "SportEvent": "sport_event",+ "Book": "book",+ "Paper": "paper",+ "VideoGame": "video_game",+ "BoardGame": "board_game",+ "GeoLocation": "geo_location",+ "Trail": "trail",+ "Beer": "beer",+ "Puzzle": "puzzle",+ "Food": "food",+ "Task": "task",+ "WebPage": "web_page",+ "LifeEvent": "life_event",+ "Mood": "mood",+ "BrickSet": "brick_set",+ "Channel": "channel",+ "BirdingLocation": "birding_location",+ "DiscGolfCourse": "disc_golf_course",+ }+ return mapping.get(media_type)+++def _scrobble_to_dict(s: Scrobble) -> dict:+ result = {+ "uuid": str(s.uuid),+ "media_type": s.media_type,+ "timestamp": s.timestamp.isoformat() if s.timestamp else None,+ "stop_timestamp": s.stop_timestamp.isoformat() if s.stop_timestamp else None,+ "in_progress": s.in_progress,+ "played_to_completion": s.played_to_completion,+ "source": s.source,+ "visibility": s.visibility,+ "timezone": s.timezone,+ }+ if s.log:+ result["log"] = s.log++ rel = _scrobble_related_to_dict(s)+ if rel:+ result["media"] = rel++ return result+++def _scrobble_related_to_dict(s: Scrobble) -> dict | None:+ if s.video:+ return _media_to_dict(s.video, fields=["title", "year", "imdb_id",+ "imdb_rating"])+ if s.track:+ return _media_to_dict(s.track, fields=["title",+ "base_run_time_seconds"])+ if s.book:+ return _media_to_dict(s.book, fields=["title", "pages"])+ if s.video_game:+ return _media_to_dict(s.video_game, fields=["title",+ "base_run_time_seconds"])+ if s.board_game:+ return _media_to_dict(s.board_game, fields=["title"])+ if s.beer:+ return _media_to_dict(s.beer, fields=["title"])+ if s.puzzle:+ return _media_to_dict(s.puzzle, fields=["title", "piece_count"])+ if s.food:+ return _media_to_dict(s.food, fields=["title"])+ if s.trail:+ return _media_to_dict(s.trail, fields=["title",+ "base_run_time_seconds"])+ if s.task:+ return _media_to_dict(s.task, fields=["title"])+ if s.web_page:+ return _media_to_dict(s.web_page, fields=["title", "url"])+ if s.life_event:+ return _media_to_dict(s.life_event, fields=["title", "event_date"])+ if s.mood:+ return _media_to_dict(s.mood, fields=["title"])+ if s.brick_set:+ return _media_to_dict(s.brick_set, fields=["title", "set_number"])+ if s.podcast_episode:+ return _media_to_dict(s.podcast_episode, fields=["title"])+ if s.sport_event:+ return {"title": str(s.sport_event)}+ if s.geo_location:+ return _media_to_dict(s.geo_location, fields=["title", "latitude",+ "longitude"])+ if s.birding_location:+ return _media_to_dict(s.birding_location, fields=["title"])+ if s.disc_golf_course:+ return _media_to_dict(s.disc_golf_course, fields=["title", "holes"])+ if s.channel:+ return _media_to_dict(s.channel, fields=["title"])+ return None+++def _media_to_dict(obj, fields: list[str] | None = None) -> dict:+ if obj is None:+ return {}+ result = {}+ if hasattr(obj, "uuid"):+ result["uuid"] = str(obj.uuid)+ if hasattr(obj, "title"):+ result["title"] = obj.title++ if fields is None:+ return result++ resolved = _resolve_fields(obj, fields)+ for k, v in resolved.items():+ if k not in result:+ result[k] = v++ return result+++def _resolve_fields(obj, fields: list[str]) -> dict:+ result = {}+ for field in fields:+ parts = field.split("__")+ val = obj+ try:+ for part in parts:+ val = getattr(val, part)+ except AttributeError:+ continue+ if val is not None:+ if hasattr(val, "all"):+ val = [str(v) for v in val.all()]+ result[field] = val+ return result@@ -236,6 +236,7 @@ INSTALLED_APPS = [ "allauth.account", "allauth.socialaccount", "django_celery_results",+ "mcp_server", ] SITE_ID = 1@@ -329,6 +330,13 @@ REST_FRAMEWORK = { "PAGE_SIZE": 200, } +DJANGO_MCP_AUTHENTICATION_CLASSES = [+ "oauth2_provider.contrib.rest_framework.OAuth2Authentication",+ "rest_framework.authentication.TokenAuthentication",+ "rest_framework.authentication.SessionAuthentication",+]+DJANGO_MCP_GET_SERVER_INSTRUCTIONS_TOOL = True+ LOGIN_REDIRECT_URL = "/" AUTH_PASSWORD_VALIDATORS = [@@ -188,6 +188,7 @@ urlpatterns = [ path("", include(people_urls, namespace="people")), path("", include(charts_urls, namespace="charts")), path("", include(trends_urls, namespace="trends")),+ path("", include("mcp_server.urls")), path("", scrobbles_views.RecentScrobbleList.as_view(), name="vrobbler-home"), ] if settings.DEBUG: