Compare commits

..

3 Commits
0.1.9 ... 0.2.0

Author SHA1 Message Date
3655cd7934 Bump version to 0.2.0 2023-01-10 14:41:56 -05:00
602d1e0ddb Add better frontend, the first of many! 2023-01-10 14:40:59 -05:00
457828e04d Fix silly bug in classmethods for Scrobbles 2023-01-10 14:40:09 -05:00
7 changed files with 432 additions and 108 deletions

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "vrobbler" name = "vrobbler"
version = "0.1.9" version = "0.2.0"
description = "" description = ""
authors = ["Colin Powell <colin@unbl.ink>"] authors = ["Colin Powell <colin@unbl.ink>"]

View File

@ -0,0 +1,70 @@
from django.db.models import Q, Count, Sum
from typing import List, Optional
from scrobbles.models import Scrobble
from music.models import Track, Artist
from videos.models import Video
from django.utils import timezone
from datetime import datetime, timedelta
NOW = timezone.now()
START_OF_TODAY = datetime.combine(NOW.date(), datetime.min.time(), NOW.tzinfo)
STARTING_DAY_OF_CURRENT_WEEK = NOW.date() - timedelta(days=NOW.today().isoweekday() % 7)
STARTING_DAY_OF_CURRENT_MONTH = NOW.date().replace(day=1)
STARTING_DAY_OF_CURRENT_YEAR = NOW.date().replace(month=1, day=1)
def scrobble_counts():
finished_scrobbles_qs = Scrobble.objects.filter(in_progress=False)
data = {}
data['today'] = finished_scrobbles_qs.filter(timestamp__gte=START_OF_TODAY).count()
data['week'] = finished_scrobbles_qs.filter(timestamp__gte=STARTING_DAY_OF_CURRENT_WEEK).count()
data['month'] = finished_scrobbles_qs.filter(timestamp__gte=STARTING_DAY_OF_CURRENT_MONTH).count()
data['year'] = finished_scrobbles_qs.filter(timestamp__gte=STARTING_DAY_OF_CURRENT_YEAR).count()
data['alltime'] = finished_scrobbles_qs.count()
return data
def week_of_scrobbles(media: str='tracks') -> dict[str, int]:
scrobble_day_dict= {}
media_filter = Q(track__isnull=True)
for day in range(1,8):
start = START_OF_TODAY - timedelta(days=day)
end = datetime.combine(start, datetime.max.time(), NOW.tzinfo)
day_of_week = start.strftime('%A')
if media == 'movies':
media_filter = Q(video__videotype=Video.VideoType.MOVIE)
if media == 'series':
media_filter = Q(video__videotype=Video.VideoType.MOVIE)
scrobble_day_dict[day_of_week] = Scrobble.objects.filter(media_filter).filter(timestamp__lte=START_OF_TODAY, timestamp__gt=end, in_progress=False).count()
return scrobble_day_dict
def top_tracks(filter: str="today", limit: int=15) -> List["Track"]:
time_filter = Q(scrobble__timestamp__gte=START_OF_TODAY)
if filter == "week":
time_filter = Q(scrobble__timestamp__gte=STARTING_DAY_OF_CURRENT_WEEK)
if filter == "month":
time_filter = Q(scrobble__timestamp__gte=STARTING_DAY_OF_CURRENT_MONTH)
if filter == "year":
time_filter = Q(scrobble__timestamp__gte=STARTING_DAY_OF_CURRENT_YEAR)
return Track.objects.annotate(num_scrobbles=Count("scrobble", distinct=True)).filter(time_filter).order_by("-num_scrobbles")[:limit]
def top_artists(filter: str="today", limit: int=15) -> List["Artist"]:
time_filter = Q(track__scrobble__timestamp__gte=START_OF_TODAY)
if filter == "week":
time_filter = Q(track__scrobble__timestamp__gte=STARTING_DAY_OF_CURRENT_WEEK)
if filter == "month":
time_filter = Q(track__scrobble__timestamp__gte=STARTING_DAY_OF_CURRENT_MONTH)
if filter == "year":
time_filter = Q(track__scrobble__timestamp__gte=STARTING_DAY_OF_CURRENT_YEAR)
return Artist.objects.annotate(num_scrobbles=Sum("track__scrobble", distinct=True)).filter(time_filter).order_by("-num_scrobbles")[:limit]
def artist_scrobble_count(artist_id: int, filter: str = "today") -> int:
return (
Scrobble.objects.filter(track__artist=artist_id)
.count()
)

View File

@ -99,7 +99,7 @@ class Scrobble(TimeStampedModel):
f"Creating or updating scrobble for video {video} with data {jellyfin_data}" f"Creating or updating scrobble for video {video} with data {jellyfin_data}"
) )
scrobble = ( scrobble = (
Scrobble.objects.filter(video=video, user_id=user_id) cls.objects.filter(video=video, user_id=user_id)
.order_by('-modified') .order_by('-modified')
.first() .first()
) )
@ -118,7 +118,7 @@ class Scrobble(TimeStampedModel):
) -> "Scrobble": ) -> "Scrobble":
scrobble_data['track_id'] = track.id scrobble_data['track_id'] = track.id
scrobble = ( scrobble = (
Scrobble.objects.filter(track=track, user_id=user_id) cls.objects.filter(track=track, user_id=user_id)
.order_by('-modified') .order_by('-modified')
.first() .first()
) )

View File

@ -21,6 +21,7 @@ from scrobbles.models import Scrobble
from scrobbles.serializers import ScrobbleSerializer from scrobbles.serializers import ScrobbleSerializer
from scrobbles.utils import convert_to_seconds from scrobbles.utils import convert_to_seconds
from videos.models import Video from videos.models import Video
from vrobbler.apps.music.aggregators import scrobble_counts, top_tracks, week_of_scrobbles
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -43,19 +44,26 @@ class RecentScrobbleList(ListView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs) data = super().get_context_data(**kwargs)
now = timezone.now() now = timezone.now()
last_three_minutes = timezone.now() - timedelta(minutes=3) last_eight_minutes = timezone.now() - timedelta(minutes=8)
# Find scrobbles from the last 10 minutes # Find scrobbles from the last 10 minutes
data['now_playing_list'] = Scrobble.objects.filter( data['now_playing_list'] = Scrobble.objects.filter(
in_progress=True, in_progress=True,
timestamp__gte=last_three_minutes, is_paused=False,
timestamp__gte=last_eight_minutes,
timestamp__lte=now, timestamp__lte=now,
) )
data['video_scrobble_list'] = Scrobble.objects.filter(video__isnull=False, in_progress=False).order_by('-timestamp')[:10]
data['top_daily_tracks'] = top_tracks()
data['top_weekly_tracks'] = top_tracks(filter='week')
data['top_monthly_tracks'] = top_tracks(filter='month')
data["weekly_data"] = week_of_scrobbles()
data['counts'] = scrobble_counts()
return data return data
def get_queryset(self): def get_queryset(self):
return Scrobble.objects.filter(in_progress=False).order_by( return Scrobble.objects.filter(track__isnull=False, in_progress=False).order_by(
'-timestamp' '-timestamp'
) )[:25]
@csrf_exempt @csrf_exempt

View File

@ -89,6 +89,7 @@ INSTALLED_APPS = [
"rest_framework", "rest_framework",
"allauth", "allauth",
"allauth.account", "allauth.account",
"allauth.socialaccount",
"django_celery_results", "django_celery_results",
] ]

View File

@ -1,4 +1,5 @@
{% load static %} {% load static %}
{% load humanize %}
<!doctype html> <!doctype html>
<html class="no-js" lang=""> <html class="no-js" lang="">
<head> <head>
@ -7,11 +8,10 @@
<meta http-equiv="x-ua-compatible" content="ie=edge"> <meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="description" content=""> <meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.0.0/dist/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script>
<script src="https://code.jquery.com/jquery-3.3.1.js" integrity="sha256-2Kok7MbOyxpgUVvAk/HJ2jigOSYS2auK4Pfzbm7uH60=" crossorigin="anonymous"></script> <script src="https://code.jquery.com/jquery-3.3.1.js" integrity="sha256-2Kok7MbOyxpgUVvAk/HJ2jigOSYS2auK4Pfzbm7uH60=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.0.0/dist/js/bootstrap.min.js" integrity="sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/2.0.10/clipboard.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/2.0.10/clipboard.min.js"></script>
<style type="text/css"> <style type="text/css">
dl { dl {
display: flex; display: flex;
@ -48,76 +48,272 @@
border-radius: 3px; border-radius: 3px;
transition: width 500ms ease-in-out; transition: width 500ms ease-in-out;
} }
.bd-placeholder-img {
font-size: 1.125rem;
text-anchor: middle;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
@media (min-width: 768px) {
.bd-placeholder-img-lg {
font-size: 3.5rem;
}
}
body {
font-size: .875rem;
}
.feather {
width: 16px;
height: 16px;
vertical-align: text-bottom;
}
/*
* Sidebar
*/
.sidebar {
position: fixed;
top: 0;
/* rtl:raw:
right: 0;
*/
bottom: 0;
/* rtl:remove */
left: 0;
z-index: 100; /* Behind the navbar */
padding: 48px 0 0; /* Height of navbar */
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
}
@media (max-width: 767.98px) {
.sidebar {
top: 5rem;
}
}
.sidebar-sticky {
position: relative;
top: 0;
height: calc(100vh - 48px);
padding-top: .5rem;
overflow-x: hidden;
overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */
}
.sidebar .nav-link {
font-weight: 500;
color: #333;
}
.sidebar .nav-link .feather {
margin-right: 4px;
color: #727272;
}
.sidebar .nav-link.active {
color: #2470dc;
}
.sidebar .nav-link:hover .feather,
.sidebar .nav-link.active .feather {
color: inherit;
}
.sidebar-heading {
font-size: .75rem;
text-transform: uppercase;
}
/*
* Navbar
*/
.navbar-brand {
padding-top: .75rem;
padding-bottom: .75rem;
font-size: 1rem;
background-color: rgba(0, 0, 0, .25);
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .25);
}
.navbar .navbar-toggler {
top: .25rem;
right: 1rem;
}
.navbar .form-control {
padding: .75rem 1rem;
border-width: 0;
border-radius: 0;
}
.form-control-dark {
color: #fff;
background-color: rgba(255, 255, 255, .1);
border-color: rgba(255, 255, 255, .1);
}
.form-control-dark:focus {
border-color: transparent;
box-shadow: 0 0 0 3px rgba(255, 255, 255, .25);
}
</style> </style>
{% block head_extra %}{% endblock %} {% block head_extra %}{% endblock %}
<link rel="apple-touch-icon" href="/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/apple-touch-icon.png">
<!-- Place favicon.ico in the root directory --> <!-- Place favicon.ico in the root directory -->
<script>
function checkUpdate(){
$.get('/library/update/status/', function(data) {
$('#library-update-status').html("");
console.log('Checking for task');
setTimeout(checkUpdate,5000);
});
}
</script>
</head> </head>
<body> <body>
<!--[if lt IE 8]> <header class="navbar navbar-dark sticky-top bg-dark flex-md-nowrap p-0 shadow">
<p class="browserupgrade"> <a class="navbar-brand col-md-3 col-lg-2 me-0 px-3" href="#">Vrobbler</a>
You are using an <strong>outdated</strong> browser. Please <button class="navbar-toggler position-absolute d-md-none collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation">
<a href="http://browsehappy.com/">upgrade your browser</a> to improve
your experience.
</p>
<![endif]-->
<div class="container">
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="/">Vrobbler</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<input class="form-control form-control-dark w-100" type="text" placeholder="Search" aria-label="Search">
<div class="collapse navbar-collapse" id="navbarSupportedContent"> <div class="navbar-nav">
<ul class="navbar-nav mr-auto"> <div class="nav-item text-nowrap">
<li class="nav-item"> {% if user.is_authenticated %}
<a class="nav-link" href="{% url 'home' %}">Recent<span class="sr-only"></span></a> <a class="nav-link px-3" href="{% url "account_logout" %}">Sign out</a>
</li> {% else %}
<li class="nav-item dropdown"> <a class="nav-link px-3" href="{% url "account_login" %}">Sign in</a>
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">Movies</a> {% endif %}
<div class="dropdown-menu" aria-labelledby="navbarDropdown"> </div>
<a class="dropdown-item" href="{% url "videos:movie_list" %}">All</a>
{% for movie in movie_list %}
<a class="dropdown-item" href="{{movie.get_absolute_url}}">{{movie.title}}</a>
{% endfor %}
</div>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">Shows</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
<a class="dropdown-item" href="{ url "games:gamecollection_list" %}">All</a>
{% for series in series_list %}
<a class="dropdown-item" href="{{series.get_absolute_url}}">{{series.name}}</a>
{% endfor %}
</div>
</li>
</ul>
</div> </div>
{% if request.user.is_authenticated %} </header>
<a class="nav-link" href="{% url 'account_logout' %}">Logout<span class="sr-only"></span></a>
{% else %}
<a class="nav-link" href="{% url 'account_login' %}">Login<span class="sr-only"></span></a>
{% endif %}
</nav>
<h1>{% block title %}{% endblock %}</h1> <div class="container-fluid">
<div class="row">
<nav id="sidebarMenu" class="col-md-3 col-lg-2 d-md-block bg-light sidebar collapse">
<div class="position-sticky pt-3">
{% if now_playing_list %}
<ul style="padding-right:10px;">
<b>Now playing</b>
{% for scrobble in now_playing_list %}
{% if scrobble.video %}
<div>
{{scrobble.video.title}}<br/>
<small>{{scrobble.created|naturaltime}}<br/>
from {{scrobble.source}}</small>
<div class="progress-bar">
<span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
</div>
</div>
{% endif %}
{% if scrobble.track %}
<div>
{{scrobble.track.title}}<br/>
<em>{{scrobble.track.artist}}</em><br/>
<small>{{scrobble.created|naturaltime}}<br/>
from {{scrobble.source}}</small>
<div class="progress-bar" style="margin-right:5px;">
<span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
</div>
</div>
{% endif %}
<hr/>
{% endfor %}
</ul>
{% endif %}
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="#">
<span data-feather="music"></span>
Tracks
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">
<span data-feather="user"></span>
Artists
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">
<span data-feather="film"></span>
Movies
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">
<span data-feather="tv"></span>
Series
</a>
</li>
{% if user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="/admin/">
<span data-feather="key"></span>
Admin
</a>
</li>
{% endif %}
</ul>
{% block extra_nav %}
{% endblock %}
</div>
</nav>
<div>
{% block content %} {% block content %}
{% endblock %} {% endblock %}
</div> </div>
</div> </div>
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/feather-icons@4.28.0/dist/feather.min.js" integrity="sha384-uO3SXW5IuS1ZpFPKugNNWqTZRRglnUJK6UAZ/gxOX80nxEkN9NcGZTftn6RzhGWE" crossorigin="anonymous"></script><script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.4/dist/Chart.min.js" integrity="sha384-zNy6FEbO50N+Cg5wap8IKA4M/ZnLJgzc6w2NqACZaK0u0FXfOWRRJOnQtpZun8ha" crossorigin="anonymous"></script>
<script><!-- comment ------------------------------------------------->
/* globals Chart:false, feather:false */
(function () {
'use strict'
feather.replace({ 'aria-hidden': 'true' })
// Graphs
var ctx = document.getElementById('myChart')
// eslint-disable-next-line no-unused-vars
var myChart = new Chart(ctx, {
type: 'line',
data: {
labels: [
{% for day in weekly_data.keys %}
"{{day}}"{% if not forloop.last %},{% endif %}
{% endfor %}
],
datasets: [{
data: [
{% for count in weekly_data.values %}
{{count}}{% if not forloop.last %},{% endif %}
{% endfor %}
],
lineTension: 0,
backgroundColor: 'transparent',
borderColor: '#007bff',
borderWidth: 4,
pointBackgroundColor: '#007bff'
}]
},
options: {
scales: {
yAxes: [{
ticks: {
beginAtZero: false
}
}]
},
legend: {
display: false
}
}
})
})()
</script>
{% endblock %}
</body> </body>
</html> </html>

View File

@ -1,50 +1,99 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load humanize %} {% load humanize %}
{% block title %}{% endblock %}
{% block content %} {% block content %}
{% if now_playing_list %} <main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
<h2>Now playing</h2> <div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
{% for scrobble in now_playing_list %} <h1 class="h2">Dashboard</h1>
{% if scrobble.video %} <div class="btn-toolbar mb-2 mb-md-0">
<dl class="latest-scrobble"> <div class="btn-group me-2">
<dt>{{scrobble.video.title}} - {{scrobble.video}}</dt> <button type="button" class="btn btn-sm btn-outline-secondary">Share</button>
<dd> <button type="button" class="btn btn-sm btn-outline-secondary">Export</button>
Started {{scrobble.created|naturaltime}} from {{scrobble.source}} </div>
<button type="button" class="btn btn-sm btn-outline-secondary dropdown-toggle">
<span data-feather="calendar"></span>
This week
</button>
</div>
</div>
<div class="progress-bar"> <canvas class="my-4 w-100" id="myChart" width="900" height="380"></canvas>
<span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
</div>
</dd> <h2>Top this week</h2>
</dl> <div class="table-responsive">
{% endif %} <table class="table table-striped table-sm">
{% if scrobble.track %} <thead>
<dl class="latest-scrobble"> <tr>
<dt>{{scrobble.track.title}} by {{scrobble.track.artist}} from {{scrobble.track.album}}</dt> <th scope="col">#</th>
<dd> <th scope="col">Track</th>
Started {{scrobble.created|naturaltime}} from {{scrobble.source}} <th scope="col">Artist</th>
<div class="progress-bar"> <th scope="col">Album</th>
<span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span> </tr>
</div> </thead>
</dd> <tbody>
</dl> {% for track in top_weekly_tracks %}
{% endif %} <tr>
<br /> <td>{{track.num_scrobbles}}</td>
{% endfor %} <td>{{track.title}}</td>
{% endif %} <td>{{track.artist.name}}</td>
<h2>Last scrobbles</h2> <td>{{track.album.name}}</td>
<ul> </tr>
{% for scrobble in object_list %} {% endfor %}
<li> </tbody>
{{scrobble.timestamp|naturaltime}}: </table>
{% if scrobble.video %} </div>
🎥 watched <a href="{{scrobble.video.imdb_link}}">{{scrobble.video}}{% if scrobble.video.video_type == 'E' %} - {{scrobble.video.title}}{% endif %}</a></li>
{% endif %} <h2>Latest scrobbles</h2>
{% if scrobble.track %} <p>Today <b>{{counts.today}}</b> | This Week <b>{{counts.week}}</b> | This Month <b>{{counts.month}}</b> | This Year <b>{{counts.year}}</b> | All Time <b>{{counts.alltime}}</b></p>
🎶 listened to <a href="{{scrobble.track.mb_link}}">{{scrobble.track.title}}</a> by <a href="{{scrobble.track.artist.mb_link}}">{{scrobble.track.artist}}</a></li> <div class="table-responsive">
{% endif %} <table class="table table-striped table-sm">
{% endfor %} <thead>
</ul> <tr>
<th scope="col">Time</th>
<th scope="col">Track</th>
<th scope="col">Artist</th>
<th scope="col">Source</th>
</tr>
</thead>
<tbody>
{% for scrobble in object_list %}
<tr>
<td>{{scrobble.timestamp|naturaltime}}</td>
<td>{{scrobble.track.title}}</td>
<td>{{scrobble.track.artist.name}}</td>
<td>{{scrobble.source}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<h2>Latest watched</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Time</th>
<th scope="col">Title</th>
<th scope="col">Series</th>
<th scope="col">Season & Episode</th>
<th scope="col">Source</th>
</tr>
</thead>
<tbody>
{% for scrobble in video_scrobble_list %}
<tr>
<td>{{scrobble.timestamp|naturaltime}}</td>
<td>{{scrobble.video.title}}</td>
<td>{% if scrobble.video.tv_series %}{{scrobble.video.tv_series}}{% endif %}</td>
<td>{% if scrobble.video.tv_series %}{{scrobble.video.season_number}}, {{scrobble.video.episode_number}}{% endif %}</td>
<td>{{scrobble.source}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</main>
{% endblock %} {% endblock %}