Compare commits

...

7 commits
v0.1.1 ... main

13 changed files with 298 additions and 33 deletions

8
.idea/.gitignore generated vendored Normal file
View file

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View file

@ -0,0 +1,36 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="HttpUrlsUsage" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredUrls">
<list>
<option value="http://0.0.0.0" />
<option value="http://127.0.0.1" />
<option value="http://activemq.apache.org/schema/" />
<option value="http://cxf.apache.org/schemas/" />
<option value="http://java.sun.com/" />
<option value="http://javafx.com/fxml" />
<option value="http://javafx.com/javafx/" />
<option value="http://json-schema.org/draft" />
<option value="http://localhost" />
<option value="http://maven.apache.org/POM/" />
<option value="http://maven.apache.org/xsd/" />
<option value="http://primefaces.org/ui" />
<option value="http://schema.cloudfoundry.org/spring/" />
<option value="http://schemas.xmlsoap.org/" />
<option value="http://tiles.apache.org/" />
<option value="http://unlicense.org" />
<option value="http://www.ibm.com/webservices/xsd" />
<option value="http://www.jboss.com/xml/ns/" />
<option value="http://www.jboss.org/j2ee/schema/" />
<option value="http://www.springframework.org/schema/" />
<option value="http://www.springframework.org/security/tags" />
<option value="http://www.springframework.org/tags" />
<option value="http://www.thymeleaf.org" />
<option value="http://www.w3.org/" />
<option value="http://xmlns.jcp.org/" />
</list>
</option>
</inspection_tool>
</profile>
</component>

View file

@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

12
.idea/lfcircle.iml generated Normal file
View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="jdk" jdkName="Poetry (lfcircle)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
</module>

7
.idea/misc.xml generated Normal file
View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Poetry (lfcircle)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Poetry (lfcircle)" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml generated Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/lfcircle.iml" filepath="$PROJECT_DIR$/.idea/lfcircle.iml" />
</modules>
</component>
</project>

10
.idea/poetry.xml generated Normal file
View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PoetryConfigService">
<option name="poetryVirtualenvPaths">
<set>
<option value="$USER_HOME$/AppData/Local/pypoetry/Cache/virtualenvs/lfcircle-XN0g8hyi-py3.12/Scripts/python.exe" />
</set>
</option>
</component>
</project>

6
.idea/ruff.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RuffConfigService">
<option name="projectRuffExecutablePath" value="C:\Users\m\AppData\Local\pypoetry\Cache\virtualenvs\lfcircle-XN0g8hyi-py3.12\Scripts\ruff.exe" />
</component>
</project>

6
.idea/vcs.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View file

@ -32,10 +32,12 @@ For more information, please refer to <http://unlicense.org/>
from argparse import ArgumentParser
from bisect import insort
from collections import Counter
from datetime import datetime, timedelta
from enum import Enum
from functools import wraps
from sys import stderr
from sys import stderr, argv
from sys import exit as sysexit
from textwrap import indent
from time import sleep
from traceback import format_exception
@ -49,6 +51,9 @@ FORMAT_TELEGRAM_PREFIX: Final[str] = " "
USER_AGENT: Final[str] = (
"Mozilla/5.0 " "(compatible; lfcircle; https://github.com/markjoshwel/lfcircle)"
)
__version__: Final[str] = "0.2.1"
GlobalTagCounter = dict[str, Counter[str]]
class FormatTypeEnum(Enum):
@ -103,6 +108,10 @@ def handle_args() -> Behaviour:
info = __doc__.strip().split("\n", maxsplit=1)[0].split(":", maxsplit=1)
default_behaviour = Behaviour()
if "--version" in argv:
print(__version__)
sysexit(0)
parser = ArgumentParser(
prog=info[0].strip(),
description=info[-1].strip(),
@ -239,6 +248,7 @@ class ListeningReport(NamedTuple):
albums_top_new: ThingWithScrobbles
tracks_top_new: ThingWithScrobbles
listening_time_hours: int
tags: dict[str, tuple[int, ...]]
def to_str(
self,
@ -249,11 +259,17 @@ class ListeningReport(NamedTuple):
leaderboard_albums_pos: int,
leaderboard_tracks_pos: int,
leaderboard_n: int,
global_tag_counter: GlobalTagCounter,
) -> str:
basket: list[str] = []
prefix: str = ""
text: str = ""
tag_counter: Counter[str]
tags: list[str]
tv: str
tc: int | float
match behaviour.format:
case FormatTypeEnum.ASCII:
# intro
@ -262,7 +278,8 @@ class ListeningReport(NamedTuple):
+ f"{self.user} — Σ{self.listening_time_hours}h; {self.scrobbles_daily_avg}s/d "
)
basket.append(
indent(f"<{self.url}>", prefix=(prefix := " " * len(_prefix))) + "\n"
indent(f"<{self.url}>", prefix=(prefix := " " * len(_prefix)))
+ "\n"
)
rmax = len(f"#{leaderboard_n}")
@ -320,6 +337,49 @@ class ListeningReport(NamedTuple):
)
basket.append(d4_l + d4_r + d4_url)
# detail 5: top five tags
if len(self.tags) > 0:
tag_counter = Counter[str]()
for tag_name, tag_values in self.tags.items():
tag_counter.update(
{tag_name: calculate_tag_score(tag_values, tags=self.tags)}
)
tags = []
for tn, _tc in tag_counter.most_common(5):
tv = ""
tc = round(_tc)
if tn in global_tag_counter:
counter = global_tag_counter[tn]
if len(counter) > 1:
_tv = sum(
[
i if (user == self.user) else 0
for i, (user, _) in enumerate(
counter.most_common(), start=1
)
]
)
tv = f": #{_tv}" if (_tv > 0) else ""
_flt_ranks = [val for _, val in counter.most_common()]
_int_ranks = [round(val) for val in _flt_ranks]
if (tc in _int_ranks) and (_int_ranks.count(tc) > 1): # type: ignore
tc = round(_tc, 2)
tags.append(f"{tn} ({tc}%{tv})")
basket.append(
indent(
("Top tags".ljust(len(d4_l) - 6) + " : " + ", ".join(tags)),
prefix=prefix,
)
)
if not behaviour.lowercase:
text = "\n".join(basket)
@ -340,7 +400,7 @@ class ListeningReport(NamedTuple):
# detail 2: total period artist count
basket.append(
f"{prefix}{self.artists_count} artists (#{leaderboard_artists_pos}): "
f"\n{prefix}{self.artists_count} artists (#{leaderboard_artists_pos}): "
+ (
f"[{self.artists[0].name}]({self.artists[0].url})"
if behaviour.all_the_links
@ -370,6 +430,44 @@ class ListeningReport(NamedTuple):
)
)
# detail 5: top tags
if len(self.tags) > 0:
tag_counter = Counter()
for tag_name, tag_values in self.tags.items():
tag_counter.update(
{tag_name: calculate_tag_score(tag_values, tags=self.tags)}
)
tags = []
for tn, _tc in tag_counter.most_common(5):
tv = ""
tc = round(_tc)
if tn in global_tag_counter:
counter = global_tag_counter[tn]
if len(counter) > 1:
_tv = sum(
[
i if (user == self.user) else 0
for i, (user, _) in enumerate(
counter.most_common(), start=1
)
]
)
tv = f": #{_tv}" if (_tv > 0) else ""
_flt_ranks = [val for _, val in counter.most_common()]
_int_ranks = [round(val) for val in _flt_ranks]
if (tc in _int_ranks) and (_int_ranks.count(tc) > 1): # type: ignore
tc = round(_tc, 2)
tags.append(f"{tn} ({tc}%{tv})")
basket.append(f"\n{prefix}Top tags" + ": " + ", ".join(tags))
if not behaviour.lowercase:
text = "\n".join(basket)
@ -392,7 +490,6 @@ def get_listening_report(
limiter: Limiter,
behaviour: Behaviour,
) -> ListeningReport:
target_url: str = f"https://www.last.fm/user/{target}/listening-report/week"
page_res: Response = limiter.limit(get)(
@ -422,20 +519,25 @@ def get_listening_report(
albums_top_new=_get_albums_top_new(page),
tracks_top_new=_get_tracks_top_new(page),
listening_time_hours=_get_listening_time_hours(page),
tags=_get_tags(page),
)
def _int(number: str) -> int:
def _int(number: str, failable: bool = False) -> int:
n = (
number.replace(",", "")
.replace("scrobbles", "")
.strip()
.lstrip("days,")
.lstrip("days")
.rstrip("hours")
.strip()
)
assert n.isnumeric()
return int(n)
if failable and n == "":
return 0
else:
assert n.isnumeric()
return int(n)
def _get_scrobbles_count(page: BeautifulSoup) -> int:
@ -466,7 +568,7 @@ def _get_listening_time_hours(page: BeautifulSoup) -> int:
days: int = _int(_d1.text)
assert (_h1 := fact.select_one(".quick-fact-data-detail")) is not None
hours: int = _int(_h1.text)
hours: int = _int(_h1.text, failable=True)
return (days * 24) + hours
@ -520,7 +622,9 @@ def _get_top_overview(
if len(top.select(select_needle)) == 0:
continue
assert (_n := top.select(".listening-report-secondary-top-item-name")) is not None
assert (
_n := top.select(".listening-report-secondary-top-item-name")
) is not None
assert (
_v := top.select(".listening-report-secondary-top-item-value")
) is not None
@ -597,6 +701,34 @@ def _get_tracks_top_new(page: BeautifulSoup) -> ThingWithScrobbles:
return _get_top_new_thing(page=page, select_needle=".top-new-item-type__track")
def _get_tags(page: BeautifulSoup) -> dict[str, tuple[int, ...]]:
tags: dict[str, tuple[int, ...]] = {}
assert (_1 := page.select_one("#top-tags-over-time")) is not None
assert (_2 := _1.select_one(".js-top-tags-over-time-table")) is not None
assert (_3 := _2.select_one("tbody")) is not None
for tr in _3.select("tr"):
tag_name: str = ""
tag_counts: list[int] = []
for i, td in enumerate(tr.select("td")):
if i == 0:
tag_name = td.text.strip()
else:
assert (
num := td.text.strip()
).isnumeric(), (
"second tag table td onwards should be numeric, but isn't"
)
tag_counts.append(int(num))
tags.update({tag_name: tuple(tag_counts)})
return tags
def _rank(
r: ListeningReport, rs: list[ListeningReport], k: Callable[[ListeningReport], int]
):
@ -605,12 +737,26 @@ def _rank(
insort(ranking, _r, key=k)
for i, _r in enumerate(reversed(ranking), start=1):
if _r.user == r.user:
if _r == r:
return i
else:
return 0
def calculate_tag_score(
tag_values: tuple[int, ...], tags: dict[str, tuple[int, ...]]
) -> float:
v_cum_sum = sum([sum(tv) for tv in tags.values()])
v_cum_avg = sum([(sum(tv) / len(tv)) for tv in tags.values()])
v_avg = (v_sum := sum(tag_values)) / len(tag_values)
tag_obj_score = (0.5 * v_avg) + (0.5 * v_sum)
tag_cum_score = (0.5 * v_cum_sum) + (0.5 * v_cum_avg)
return 100 * (tag_obj_score / tag_cum_score)
def make_circle_report(
listening_reports: list[ListeningReport],
behaviour: Behaviour,
@ -627,6 +773,18 @@ def make_circle_report(
case FormatTypeEnum.TELEGRAM:
text.append(behaviour.header + "\n")
# pre-string building global tag versus calculation
global_tag_counter: GlobalTagCounter = {}
for report in listening_reports:
for tag_name, tag_value in report.tags.items():
if tag_name not in global_tag_counter:
global_tag_counter[tag_name] = Counter()
global_tag_counter[tag_name][report.user] += calculate_tag_score(
tag_value, tags=report.tags
) # type: ignore
for leaderboard_pos, report in enumerate(
reversed(
sorted(
@ -643,24 +801,25 @@ def make_circle_report(
leaderboard_scrobble_pos=_rank(
r=report,
rs=listening_reports,
k=lambda r: r.listening_time_hours + r.scrobbles_count,
k=lambda r: r.scrobbles_count,
),
leaderboard_artists_pos=_rank(
r=report,
rs=listening_reports,
k=lambda r: r.listening_time_hours + r.artists_count,
k=lambda r: r.artists_count,
),
leaderboard_albums_pos=_rank(
r=report,
rs=listening_reports,
k=lambda r: r.listening_time_hours + r.albums_count,
k=lambda r: r.albums_count,
),
leaderboard_tracks_pos=_rank(
r=report,
rs=listening_reports,
k=lambda r: r.listening_time_hours + r.tracks_count,
k=lambda r: r.tracks_count,
),
leaderboard_n=len(listening_reports),
global_tag_counter=global_tag_counter,
)
+ "\n"
)
@ -674,7 +833,7 @@ def cli() -> None:
reports: list[ListeningReport] = []
print(behaviour, file=stderr) if behaviour.verbose else ...
for i, target in enumerate(behaviour.targets):
for i, target in enumerate(set(behaviour.targets)):
try:
reports.append(
get_listening_report(

34
poetry.lock generated
View file

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand.
# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand.
[[package]]
name = "beautifulsoup4"
@ -330,13 +330,13 @@ files = [
[[package]]
name = "platformdirs"
version = "4.2.1"
version = "4.2.2"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
optional = false
python-versions = ">=3.8"
files = [
{file = "platformdirs-4.2.1-py3-none-any.whl", hash = "sha256:17d5a1161b3fd67b390023cb2d3b026bbd40abde6fdb052dfbd3a29c3ba22ee1"},
{file = "platformdirs-4.2.1.tar.gz", hash = "sha256:031cd18d4ec63ec53e82dceaac0417d218a6863f7745dfcc9efe7793b7039bdf"},
{file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"},
{file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"},
]
[package.extras]
@ -346,13 +346,13 @@ type = ["mypy (>=1.8)"]
[[package]]
name = "requests"
version = "2.31.0"
version = "2.32.3"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.7"
python-versions = ">=3.8"
files = [
{file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"},
{file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"},
{file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"},
{file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"},
]
[package.dependencies]
@ -400,13 +400,13 @@ files = [
[[package]]
name = "types-beautifulsoup4"
version = "4.12.0.20240504"
version = "4.12.0.20240511"
description = "Typing stubs for beautifulsoup4"
optional = false
python-versions = ">=3.8"
files = [
{file = "types-beautifulsoup4-4.12.0.20240504.tar.gz", hash = "sha256:d7b7af4ccc52fc22784d33a529695e34329a9bdd5db6a649c9b25cb2c3a148d5"},
{file = "types_beautifulsoup4-4.12.0.20240504-py3-none-any.whl", hash = "sha256:84e04e4473b3c79da04d9d1f89d20a38e5cc92f9c080a8e1f9d36a287b350465"},
{file = "types-beautifulsoup4-4.12.0.20240511.tar.gz", hash = "sha256:004f6096fdd83b19cdbf6cb10e4eae57b10205eccc365d0a69d77da836012e28"},
{file = "types_beautifulsoup4-4.12.0.20240511-py3-none-any.whl", hash = "sha256:7ceda66a93ba28d759d5046d7fec9f4cad2f563a77b3a789efc90bcadafeefd1"},
]
[package.dependencies]
@ -425,13 +425,13 @@ files = [
[[package]]
name = "types-requests"
version = "2.31.0.20240406"
version = "2.32.0.20240523"
description = "Typing stubs for requests"
optional = false
python-versions = ">=3.8"
files = [
{file = "types-requests-2.31.0.20240406.tar.gz", hash = "sha256:4428df33c5503945c74b3f42e82b181e86ec7b724620419a2966e2de604ce1a1"},
{file = "types_requests-2.31.0.20240406-py3-none-any.whl", hash = "sha256:6216cdac377c6b9a040ac1c0404f7284bd13199c0e1bb235f4324627e8898cf5"},
{file = "types-requests-2.32.0.20240523.tar.gz", hash = "sha256:26b8a6de32d9f561192b9942b41c0ab2d8010df5677ca8aa146289d11d505f57"},
{file = "types_requests-2.32.0.20240523-py3-none-any.whl", hash = "sha256:f19ed0e2daa74302069bbbbf9e82902854ffa780bc790742a810a9aaa52f65ec"},
]
[package.dependencies]
@ -439,13 +439,13 @@ urllib3 = ">=2"
[[package]]
name = "typing-extensions"
version = "4.11.0"
version = "4.12.0"
description = "Backported and Experimental Type Hints for Python 3.8+"
optional = false
python-versions = ">=3.8"
files = [
{file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"},
{file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"},
{file = "typing_extensions-4.12.0-py3-none-any.whl", hash = "sha256:b349c66bea9016ac22978d800cfff206d5f9816951f12a7d0ec5578b0a819594"},
{file = "typing_extensions-4.12.0.tar.gz", hash = "sha256:8cbcdc8606ebcb0d95453ad7dc5065e6237b6aa230a31e81d0f440c30fed5fd8"},
]
[[package]]

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "lfcircle"
version = "0.1.1"
version = "0.2.1"
description = "last.fm statistics generator for your friend circle!"
authors = ["Mark Joshwel <mark@joshwel.co>"]
license = "Unlicense"

7
qc.sh Executable file
View file

@ -0,0 +1,7 @@
#!/bin/sh
set -e
black lfcircle.py --check
isort lfcircle.py --check
mypy lfcircle.py
ruff check lfcircle.py