rss feed and download stats

This commit is contained in:
2026-07-28 10:42:39 +02:00
parent 746143decd
commit 4afa440b12
20 changed files with 186 additions and 14 deletions
@@ -0,0 +1,11 @@
class Api::RssController < ApiController
def blog
xml = RssService.new.blog_feed
render xml: xml, content_type: "application/rss+xml"
end
def releases
xml = RssService.new.releases_feed
render xml: xml, content_type: "application/rss+xml"
end
end
@@ -0,0 +1,23 @@
class DownloadsController < ApplicationController
skip_forgery_protection
def show
path = params[:path]
return head :bad_request if path.blank?
full_path = DownloadService.new.call(
path: path,
ip: request.remote_ip,
user_agent: request.user_agent,
referer: request.referer
)
if full_path
ext = File.extname(full_path.to_s).delete_prefix(".")
mime = Mime::Type.lookup_by_extension(ext) || "application/octet-stream"
send_file full_path, disposition: "attachment", type: mime
else
head :not_found
end
end
end
+5
View File
@@ -0,0 +1,5 @@
class Download < ApplicationRecord
belongs_to :release, optional: true
validates :file_path, presence: true
end
+1
View File
@@ -2,6 +2,7 @@ class Release < ApplicationRecord
self.table_name = "releases"
belongs_to :software
has_many :downloads
default_scope { where(deleted_at: nil) }
+1
View File
@@ -4,6 +4,7 @@ class Software < ApplicationRecord
has_many :software_images, foreign_key: :software_id, dependent: :destroy
has_many :images, through: :software_images
has_many :releases, foreign_key: :software_id
has_many :downloads, through: :releases
has_many :external_links, foreign_key: :software_id
accepts_nested_attributes_for :software_images, allow_destroy: true
@@ -14,4 +14,5 @@ class ReleaseSerializer < Blueprinter::Base
field(:sourcePath) { |r| r.source_path.blank? ? "" : r.source_path.gsub(FILE_PATH_FROM, FILE_PATH_TO) }
field(:htmlFolderPath) { |r| r.html_folder_path.blank? ? "" : r.html_folder_path.gsub(FILE_PATH_FROM, FILE_PATH_TO) }
field(:docsFolderPath) { |r| r.docs_folder_path.blank? ? "" : r.docs_folder_path.gsub(FILE_PATH_FROM, FILE_PATH_TO) }
field(:downloadCount) { |r| r.downloads.size }
end
@@ -3,4 +3,5 @@ class SoftwareDetailSerializer < Blueprinter::Base
field(:releases) { |_, opts| ReleaseSerializer.render_as_hash(opts[:releases]) }
field(:latestRelease) { |_, opts| opts[:latest] ? ReleaseSerializer.render_as_hash(opts[:latest]) : nil }
field(:webPlayableRelease) { |_, opts| opts[:web_playable] ? ReleaseSerializer.render_as_hash(opts[:web_playable]) : nil }
field(:totalDownloads) { |sw, _| Download.where(release: sw.releases).count }
end
+21
View File
@@ -0,0 +1,21 @@
class DownloadService
BASE_PATH = Pathname.new(ENV.fetch("FILE_CONTAINER_PATH", "/softwares")).realpath
def call(path:, ip:, user_agent:, referer:)
full_path = BASE_PATH.join(path.to_s)
return nil unless File.file?(full_path)
release = Release.find_by("cartridge_path LIKE ? OR source_path LIKE ?",
"%#{path}%", "%#{path}%")
Download.create!(
file_path: path,
release: release,
ip_address: ip,
user_agent: user_agent&.truncate(500),
referer: referer&.truncate(500)
)
full_path
end
end
+51
View File
@@ -0,0 +1,51 @@
require "rss"
class RssService
SITE_URL = ENV.fetch("SITE_URL", "https://teletypegames.hu").freeze
def blog_feed
pages = WikiService.new.pages(tag: "blog", limit: 30)
items = pages.fetch("pages", [])
RSS::Maker.make("2.0") do |maker|
maker.channel.title = "Teletype Games Blog"
maker.channel.link = "#{SITE_URL}/blog"
maker.channel.description = "Latest blog posts from Teletype Games"
maker.channel.language = "hu"
items.each do |page|
maker.items.new_item do |item|
item.title = page["title"]
item.link = "#{SITE_URL}/blog/#{page["route"]}"
item.description = page["description"].to_s
item.pubDate = Time.parse(page["createdAt"]) rescue Time.current
end
end
end.to_s
end
def releases_feed
releases = Release.includes(:software)
.order(created_at: :desc)
.limit(50)
RSS::Maker.make("2.0") do |maker|
maker.channel.title = "Teletype Games Releases"
maker.channel.link = "#{SITE_URL}/catalog"
maker.channel.description = "Latest game releases from Teletype Games"
maker.channel.language = "hu"
releases.each do |release|
sw = release.software
next unless sw
maker.items.new_item do |item|
item.title = "#{sw.title} v#{release.version}"
item.link = "#{SITE_URL}/catalog/#{sw.name}"
item.description = "#{sw.title} #{release.version} released #{sw.desc}"
item.pubDate = release.created_at
end
end
end.to_s
end
end
@@ -8,7 +8,7 @@ class SoftwareHighlightedService
.first
return nil unless software
releases = Release.where(software_id: software.id).to_a
releases = Release.includes(:downloads).where(software_id: software.id).to_a
build_response(software, releases)
end
end
+1 -1
View File
@@ -2,7 +2,7 @@ class SoftwareService
include SoftwareResponseBuilder
def index
softwares = Software.includes(:releases, :external_links, :software_images).all
softwares = Software.includes(releases: :downloads).includes(:external_links, :software_images).all
{ softwares: softwares.map { |sw| build_response(sw, sw.releases.to_a) } }
end
end
+3
View File
@@ -9,8 +9,11 @@ Rails.application.routes.draw do
get "members", to: "members#index"
get "image/:id", to: "images#show"
get "wiki/pages", to: "wiki#index"
get "rss/blog", to: "rss#blog"
get "rss/releases", to: "rss#releases"
end
get "download", to: "downloads#show"
get "update", to: "update#update"
get "file/*path", to: "files#show", format: false
end
@@ -0,0 +1,19 @@
class CreateDownloads < ActiveRecord::Migration[8.1]
def change
create_table :downloads, id: { type: :bigint, unsigned: true },
charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
t.string :file_path, null: false
t.bigint :release_id, unsigned: true
t.string :ip_address
t.string :user_agent, limit: 500
t.string :referer, limit: 500
t.datetime :created_at, precision: 3
t.datetime :updated_at, precision: 3
t.index :file_path
t.index :release_id
end
add_foreign_key :downloads, :releases, name: "fk_downloads_release" if table_exists?(:releases)
end
end
+2
View File
@@ -6,6 +6,8 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" />
<title>Teletype Games</title>
<link rel="alternate" type="application/rss+xml" title="Teletype Games Blog" href="/api/rss/blog" />
<link rel="alternate" type="application/rss+xml" title="Teletype Games Releases" href="/api/rss/releases" />
</head>
<body>
<div id="app"></div>
+3
View File
@@ -9,6 +9,7 @@ export default {
contact: 'Contact us',
retro: 'Retro Mode',
exitRetro: 'Exit Retro Mode',
rss: 'RSS',
},
common: {
status: {
@@ -120,6 +121,7 @@ export default {
platform: 'Platform:',
play: '▶ Play',
moreInfo: ' More Info',
downloads: 'downloads',
},
catalogShow: {
back: '← Back to Catalog',
@@ -142,6 +144,7 @@ export default {
failedToLoad: 'Failed to load:',
loading: 'Loading...',
play: '▶ Play',
downloads: 'downloads',
},
team: {
title: 'Our Team',
+3
View File
@@ -9,6 +9,7 @@ export default {
contact: 'Kapcsolat',
retro: 'Retró mód',
exitRetro: 'Retró mód kikapcsolása',
rss: 'RSS',
},
common: {
status: {
@@ -120,6 +121,7 @@ export default {
platform: 'Platform:',
play: '▶ Játék',
moreInfo: ' Részletek',
downloads: 'letöltés',
},
catalogShow: {
back: '← Vissza a katalógushoz',
@@ -142,6 +144,7 @@ export default {
failedToLoad: 'Nem sikerült betölteni:',
loading: 'Betöltés...',
play: '▶ Játék',
downloads: 'letöltés',
},
team: {
title: 'Csapatunk',
+14 -3
View File
@@ -49,9 +49,20 @@
</main>
<div class="retro-toggle-bar">
<button class="retro-toggle-btn" @click="toggleRetroMode">
🖥 {{ isRetroMode ? t('nav.exitRetro') : t('nav.retro') }}
</button>
<div class="flex items-center justify-center gap-4">
<button class="retro-toggle-btn" @click="toggleRetroMode">
🖥 {{ isRetroMode ? t('nav.exitRetro') : t('nav.retro') }}
</button>
<div class="flex items-center gap-2 text-xs text-gray-400">
<a href="/api/rss/blog" target="_blank" class="hover:text-orange-400 transition-colors" title="Blog RSS">
📡 Blog
</a>
<span class="text-gray-600">|</span>
<a href="/api/rss/releases" target="_blank" class="hover:text-orange-400 transition-colors" title="Releases RSS">
📡 Releases
</a>
</div>
</div>
</div>
</div>
</template>
@@ -4,6 +4,7 @@ export interface Release {
cartridgePath?: string
sourcePath?: string
docsFolderPath?: string
downloadCount?: number
UpdatedAt: string
}
@@ -30,4 +31,5 @@ export interface Software {
export interface SoftwareEntry {
software: Software
releases: Release[]
totalDownloads?: number
}
@@ -51,6 +51,9 @@
<RouterLink :to="`/catalog/${software.name}`" class="btn-info-sm">
{{ t('catalog.moreInfo') }}
</RouterLink>
<span v-if="getTotalDownloads(software.name)" class="text-xs text-gray-400 font-medium ml-1">
{{ getTotalDownloads(software.name) }} {{ t('catalog.downloads') }}
</span>
</div>
</div>
</div>
@@ -66,7 +69,7 @@ import { storeToRefs } from 'pinia'
import { RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useSoftwareStore } from '../../stores/software.store'
import type { Software } from '../../lib/interfaces/software.interface'
import type { Software, SoftwareEntry } from '../../lib/interfaces/software.interface'
const { t } = useI18n()
@@ -77,6 +80,11 @@ const store = useSoftwareStore()
const { items: softwares } = storeToRefs(store)
const { getLatestStable } = store
function getTotalDownloads(name: string): number | null {
const entry = softwares.value.find((e: SoftwareEntry) => e.software.name === name)
return entry?.totalDownloads || null
}
function getDefaultImageUrl(sw: Software): string | null {
if (sw.images?.length) {
const def = sw.images.find(i => i.isDefault)
@@ -95,8 +95,8 @@
<div class="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-2 xl:grid-cols-4 gap-2 w-full md:w-auto">
<a v-if="latestStable.htmlFolderPath" :href="latestStable.htmlFolderPath" target="_blank" class="flex items-center justify-center py-3 px-4 bg-white text-indigo-900 font-bold rounded-xl transition-all hover:bg-indigo-50 shadow-lg shadow-black/10 text-sm whitespace-nowrap">{{ t('catalogShow.playNow') }}</a>
<a v-if="latestStable.cartridgePath" :href="latestStable.cartridgePath" target="_blank" class="flex items-center justify-center py-3 px-4 bg-indigo-500/30 border border-indigo-400/30 text-white font-bold rounded-xl transition-all hover:bg-indigo-500/40 text-sm whitespace-nowrap">{{ t('catalogShow.download') }}</a>
<a v-if="latestStable.sourcePath" :href="latestStable.sourcePath" target="_blank" class="flex items-center justify-center py-3 px-4 bg-indigo-500/30 border border-indigo-400/30 text-white font-bold rounded-xl transition-all hover:bg-indigo-500/40 text-sm whitespace-nowrap">{{ t('catalogShow.source') }}</a>
<a v-if="latestStable.cartridgePath" :href="downloadUrl(latestStable.cartridgePath)" class="flex items-center justify-center py-3 px-4 bg-indigo-500/30 border border-indigo-400/30 text-white font-bold rounded-xl transition-all hover:bg-indigo-500/40 text-sm whitespace-nowrap">{{ t('catalogShow.download') }}</a>
<a v-if="latestStable.sourcePath" :href="downloadUrl(latestStable.sourcePath)" class="flex items-center justify-center py-3 px-4 bg-indigo-500/30 border border-indigo-400/30 text-white font-bold rounded-xl transition-all hover:bg-indigo-500/40 text-sm whitespace-nowrap">{{ t('catalogShow.source') }}</a>
<a v-if="latestStable.docsFolderPath" :href="latestStable.docsFolderPath" target="_blank" class="flex items-center justify-center py-3 px-4 bg-indigo-500/30 border border-indigo-400/30 text-white font-bold rounded-xl transition-all hover:bg-indigo-500/40 text-sm whitespace-nowrap">{{ t('catalogShow.docs') }}</a>
</div>
</div>
@@ -119,11 +119,14 @@
<tbody class="divide-y divide-gray-100">
<tr v-for="release in stableReleases" :key="release.version" class="hover:bg-gray-50/50 transition-colors">
<td class="px-8 py-4">
<div class="font-bold text-gray-900 text-lg mb-2">{{ release.version }}</div>
<div class="flex items-center gap-2 mb-2">
<span class="font-bold text-gray-900 text-lg">{{ release.version }}</span>
<span v-if="release.downloadCount" class="text-xs text-gray-400 font-medium"> {{ release.downloadCount }}</span>
</div>
<div class="flex flex-wrap gap-4">
<a v-if="release.htmlFolderPath" :href="release.htmlFolderPath" target="_blank" class="text-purple-600 hover:text-purple-900 font-bold text-sm flex items-center gap-1">{{ t('catalogShow.play') }}</a>
<a v-if="release.cartridgePath" :href="release.cartridgePath" target="_blank" class="text-blue-600 hover:text-blue-900 font-bold text-sm flex items-center gap-1">{{ t('catalogShow.download') }}</a>
<a v-if="release.sourcePath" :href="release.sourcePath" target="_blank" class="text-green-600 hover:text-green-900 font-bold text-sm flex items-center gap-1">{{ t('catalogShow.source') }}</a>
<a v-if="release.cartridgePath" :href="downloadUrl(release.cartridgePath)" class="text-blue-600 hover:text-blue-900 font-bold text-sm flex items-center gap-1">{{ t('catalogShow.download') }}</a>
<a v-if="release.sourcePath" :href="downloadUrl(release.sourcePath)" class="text-green-600 hover:text-green-900 font-bold text-sm flex items-center gap-1">{{ t('catalogShow.source') }}</a>
<a v-if="release.docsFolderPath" :href="release.docsFolderPath" target="_blank" class="text-yellow-600 hover:text-yellow-900 font-bold text-sm flex items-center gap-1">{{ t('catalogShow.docs') }}</a>
</div>
</td>
@@ -139,7 +142,7 @@
<div class="font-bold text-yellow-800 text-lg mb-2">{{ release.version }}</div>
<div class="flex flex-wrap gap-4">
<a v-if="release.htmlFolderPath" :href="release.htmlFolderPath" target="_blank" class="text-purple-600 hover:text-purple-900 font-bold text-sm flex items-center gap-1">{{ t('catalogShow.play') }}</a>
<a v-if="release.cartridgePath" :href="release.cartridgePath" target="_blank" class="text-blue-600 hover:text-blue-900 font-bold text-sm flex items-center gap-1">{{ t('catalogShow.download') }}</a>
<a v-if="release.cartridgePath" :href="downloadUrl(release.cartridgePath)" class="text-blue-600 hover:text-blue-900 font-bold text-sm flex items-center gap-1">{{ t('catalogShow.download') }}</a>
</div>
</td>
<td class="px-8 py-4 text-gray-500 align-top pt-5">{{ formatDateTime(release.UpdatedAt) }}</td>
@@ -164,8 +167,8 @@
</div>
<div class="grid grid-cols-2 gap-2">
<a v-if="release.htmlFolderPath" :href="release.htmlFolderPath" target="_blank" class="flex items-center justify-center py-2 bg-purple-100 text-purple-700 font-bold rounded-lg text-sm">{{ t('catalogShow.play') }}</a>
<a v-if="release.cartridgePath" :href="release.cartridgePath" target="_blank" class="flex items-center justify-center py-2 bg-blue-100 text-blue-700 font-bold rounded-lg text-sm">{{ t('catalogShow.download') }}</a>
<a v-if="release.sourcePath" :href="release.sourcePath" target="_blank" class="flex items-center justify-center py-2 bg-green-100 text-green-700 font-bold rounded-lg text-sm">{{ t('catalogShow.source') }}</a>
<a v-if="release.cartridgePath" :href="downloadUrl(release.cartridgePath)" class="flex items-center justify-center py-2 bg-blue-100 text-blue-700 font-bold rounded-lg text-sm">{{ t('catalogShow.download') }}</a>
<a v-if="release.sourcePath" :href="downloadUrl(release.sourcePath)" class="flex items-center justify-center py-2 bg-green-100 text-green-700 font-bold rounded-lg text-sm">{{ t('catalogShow.source') }}</a>
<a v-if="release.docsFolderPath" :href="release.docsFolderPath" target="_blank" class="flex items-center justify-center py-2 bg-yellow-100 text-yellow-700 font-bold rounded-lg text-sm">{{ t('catalogShow.docs') }}</a>
</div>
</div>
@@ -250,6 +253,9 @@ const devReleases = computed(() =>
const allReleases = computed(() => [...stableReleases.value, ...devReleases.value])
const latestStable = computed(() => stableReleases.value[0] ?? null)
const downloadUrl = (path: string) =>
`/download?path=${encodeURIComponent(path.replace('/file/', ''))}`
onMounted(async () => {
try {
await store.fetchAll()