بناء Gocaster: عميل بودكاست طرفيّ بهندسة نظيفة في Go Skip to content
→ العودة إلى المدونة
بناء Gocaster: عميل بودكاست طرفيّ بهندسة نظيفة في Go

بناء Gocaster: عميل بودكاست طرفيّ بهندسة نظيفة في Go

أستمع إلى بودكاست كثيراً. ومثل أي شخص محترم في الأنظمة، لم أكن راضياً عن عملاء الواجهات الرسومية. إنها ثقيلة، ولا تندمج في سير عملي الطرفيّ أولاً، والأأسوأ من كل ذلك — أنها لا تتكامل مع بقية أدواتي. لذا فعلت الشيء الوحيد المعقول: بنيتُ عميلي الخاص.

أسميته Gocaster — عميل بودكاست للطرفية مكتوب بلغة Go. إنه يفعل ما تتوقعه: إدارة الاشتراكات، وجلب موجات RSS، وتصفّح الحلقات، وتشغيلها داخلياً، وتنزيلها للاستماع دون اتصال. لكن الجزء الذي أفخر به أكثر ليس قائمة المزايا — بل كيف رُصّت الأجزاء معاً.

شعار Gocaster

هذا المقال جولة في الهندسة: لماذا لجأت إلى الهندسة النظيفة في تطبيق كان يمكن أن يكون أداة سطر أوامر سريعة وعفِنة، وكيف تبرّر كل طبقة وجودها. أحضر المتّة الخاصة بك. 🧉


🎯 مجموعة المزايا

قبل أن نغوص في الكود، إليك ما يفعله Gocaster فعلاً:

  • إدارة البودكاست — إضافة الموجات عبر الرابط، والتحديث، والمزامنة التلقائية عند الإقلاع، ومزامنة دورية في الخلفية
  • تصفّح الحلقات — تنقّل بـ j/k، وبحث بـ /، ومفاتيح تبديل الترتيب، ومؤشرات حالة جديد/مُشغّل
  • التشغيل الداخلي — عبر libmpv (عبر روابط go-mpv)، لا عبر إنشاء عملية mpv فرعية
  • طابور التنزيل — تنزيلات في الخلفية مع دعم الاستئناف وتتبّع التقدّم
  • التكامل مع النظام — تحكم عبر MPRIS (مفاتيح الوسائط لديك تعمل) و Discord Rich Presence
  • السمات — 14 سمة جاهزة بالإضافة إلى سمات مخصّصة تُحمَّل من TOML
  • الإعدادات — نافذة إعدادات، وإعدادات محفوظة، وفترات مزامنة تلقائية

ما بدأ كهيكل عظمي في أحد بعد ظهور أيام أبريل، تحوّل إلى تطبيق متكرك بشكل مدهش على مدى نحو ستة أسابيع من الأمسيات. والسبب الذي أبقاه قابلاً للإدارة؟ اتُّخذ قرار الهندسة في اليوم الأول.


🏛️ لماذا الهندسة النظيفة لتطبيق TUI؟

أحياناً تُكتَب الهندسة النظيفة بسوء سمعة — «جافا المؤسسية في ثوب مزيف»، ومخططات دوائر داخل دوائر، وواجهات IWidgetFactoryFactory مجرّدة. نقد عادل حين تُستنسخ بلا فهم. لكن الفكرة الجوهرية بسيطة ومفيدة فعلاً:

الاتجاهات تشير إلى الداخل. منطق الأعمال لا يعرف شيئاً عن قاعدة البيانات، أو الشبكة، أو الواجهة.

بالنسبة لتطبيق TUI، هذا يثمر فوراً تقريباً:

  • يمكنني اختبار PodcastService دون تشغيل SQLite أو ضرب موجة RSS حقيقية.
  • استبدلتُ المشغّل من أمر mpv فرعي إلى نسخة libmpv مضمَّنة — دون لمس سطر واحد من منطق الأعمال.
  • أضفتُ Discord Rich Presence بإسقاط مُذيع ثانٍ إلى جانب MPRIS — وبقية التطبيق لم تنتبه إطلاقاً.

آخر نقطتين ليستا افتراضيتين. كلتاهما في سجلّ git. هذا كل العرض.

كعكة الطبقات

cmd/gocaster/              → نقطة الدخول وتوصيل الاعتماديات
internal/
├── domain/                → الكيانات + واجهات المنفذ (النواة)
├── application/           → حالات الاستخدام / الخدمات
├── infrastructure/        → المُكيّفات: sqlite، rss، mpv، mpris، discord
└── interface/tui/         → العرض (Bubble Tea)

لنمرّ على كل طبقة.


🧬 طبقة النطاق: مفاهيم الأعمال الصافية

الطبقة الأعمق تحتفظ بالكيانات وبـ الواجهات (المنافذ) التي تُعرّف ما يحتاجه منطق الأعمال. والأهم: لا استيراد من الطبقات الخارجية — لا database/sql، ولا HTTP، ولا إطار TUI.

// internal/domain/entity.go
package domain

import "time"

type Podcast struct {
	ID          int64
	Title       string
	FeedURL     string
	Description string
	ImageURL    string
	LastUpdated time.Time
}

type Episode struct {
	ID               int64
	PodcastID        int64
	Title            string
	Description      string
	AudioURL         string
	PublishedAt      time.Time
	PlaybackDuration int // بالثواني
	IsPlayed         bool
	IsDownloaded     bool
	LocalPath        string
}

بنى (structs) عادية. لا وسوم لـ ORM معيّن، ولا حواشٍ لأي إطار. وبجانبها تعيش الواجهات التي تعتمد عليها طبقة التطبيق — منفذ مستودع ومنفذ محلّل موجات:

// internal/domain/repository.go
type PodcastRepository interface {
	Save(podcast *Podcast) error
	FindByID(id int64) (*Podcast, error)
	FindEpisodesByPodcastID(id int64) ([]Episode, error)
	SaveEpisode(episode *Episode) error
	UpdateEpisodePlaybackState(id int64, isPlayed bool) error
	// ...وظائف التنزيل، إلخ
}

لاحظ ما ليس هنا: لا sql.DB، ولا أسماء جداول، ولا نصوص استعلامات. هذه مشكلة تنفيذ SQLite، وهو يعيش على بُعد طبقتين إلى الخارج.


⚙️ طبقة التطبيق: حالات الاستخدام

هنا تعيش قواعد الأعمال. تنسّق الخدمات كيانات النطاق والمنافذ. إليك PodcastService يضيف بودكاست عبر جلب موجته ثم حفظ كل شيء:

// internal/application/podcast_service.go
type FeedParser interface {
	Parse(url string) (*domain.Podcast, []domain.Episode, error)
}

type PodcastService struct {
	repo    domain.PodcastRepository
	fetcher FeedParser
}

func NewPodcastService(repo domain.PodcastRepository, fetcher FeedParser) *PodcastService {
	return &PodcastService{repo: repo, fetcher: fetcher}
}

func (s *PodcastService) AddPodcast(rssUrl string) (*domain.Podcast, error) {
	// جلب البيانات الوصفية من موجة RSS
	podcast, episodes, err := s.fetcher.Parse(rssUrl)
	if err != nil {
		return nil, err
	}

	if err := s.repo.Save(podcast); err != nil {
		return nil, err
	}

	for i := range episodes {
		episodes[i].PodcastID = podcast.ID
		if err := s.repo.SaveEpisode(&episodes[i]); err != nil {
			return nil, err
		}
	}

	return podcast, nil
}

ألاحظت الحيلة؟ PodcastService يعتمد على الواجهة FeedParser، لا على تنفيذ gofeed الملموس. هذا مبدأ انعكاس الاعتمادية يؤدي عمله.

🧪 لماذا يهمّ هذا في الاختبار

في الإنتاج، يستند FeedParser إلى gofeed يضرب روابط حقيقية. في الاختبارات، أمرّر مزوّراً يعيد بيانات جاهزة. منطق الخدمة — الجزء الذي يهمّ فعلاً — يُختبَر دون أي استدعاءات شبكية:

// في اختبار:
fakeFetcher := &stubFeedParser{podcast: p, episodes: eps}
svc := application.NewPodcastService(inMemoryRepo, fakeFetcher)
// التحقق من سلوك svc.AddPodcast(...)

لا مكتبة محاكاة، ولا خادم HTTP، ولا تقلّبات. الواجهة جعلت الأمر تافهاً.


🏗️ طبقة البنية التحتية: المُكيّفات

هنا يلتقي المطاط بالطريق — تنفيذات ملموسة لكل تلك المنافذ.

مستودع SQLite

يحصل منفذ المستودع على تنفيذ مدعوم بـ database/sql. تُشغَّل الهجرات تلقائياً عند التهيئة:

// internal/infrastructure/persistence/sqlite_repo.go
func NewSQLiteRepo(dsn string) (*SQLiteRepo, error) {
	db, err := sql.Open("sqlite3", dsn)
	if err != nil {
		return nil, err
	}

	// تشغيل الهجرات
	if err := RunMigrations(db); err != nil {
		db.Close()
		return nil, err
	}

	return &SQLiteRepo{db: db}, nil
}

يعيش المخطط في migrations.go — جداول podcasts وepisodes وdownloads مع الفهارس الصحيحة (idx_episodes_podcast_id، idx_episodes_published_at). إنه تكراري (idempotent)، لذا فهو آمن عند كل إقلاع.

جالب موجات RSS

يحصل منفذ FeedParser على مُكيّف يستند إلى gofef يربط بنى المكتبة إلى كيانات نطاقنا النظيفة:

// internal/infrastructure/rss/feed_fetcher.go
func (f *FeedFetcher) Parse(url string) (*domain.Podcast, []domain.Episode, error) {
	fp := gofeed.NewParser()
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	feed, err := fp.ParseURLWithContext(url, ctx)
	if err != nil {
		return nil, nil, err
	}

	podcast := &domain.Podcast{
		Title:       feed.Title,
		FeedURL:     url,
		Description: feed.Description,
	}

	episodes := make([]domain.Episode, 0, len(feed.Items))
	for _, item := range feed.Items {
		if len(item.Enclosures) == 0 {
			continue // تخطّي العناصر غير الصوتية
		}
		episode := domain.Episode{
			Title:       item.Title,
			Description: item.Description,
			AudioURL:    item.Enclosures[0].URL,
		}
		if item.ITunesExt != nil && item.ITunesExt.Duration != "" {
			episode.PlaybackDuration = parseDuration(item.ITunesExt.Duration)
		}
		episodes = append(episodes, episode)
	}
	return podcast, episodes, nil
}

واقع RSS الفوضوي — امتدادات iTunes، ونصوص مدّة مثل "2:09:56"، والمرفقات المفقودة — محصور هنا. طبقة النطاق لا تراه أبداً.


🎮 طبقة الواجهة: واجهة Bubble Tea

الطبقة الأبعد هي العرض. استخدمتُ حزمة Charmbubbletea إطار TUI قوي مبني على معمارية Elm، وbubbles للمكوّنات (قوائم، منافذ عرض، حقول نصية، مؤثرات دورانية)، وlipgloss للتنسيق.

يتبع Bubble Tea حلقة Model-Update-View الكلاسيكية:

  • Model — حالة التطبيق الكاملة (العرض الحالي، التركيز، البودكاست/الحلقة المحددة، حالة التشغيل، الأبعاد، الإعدادات…)
  • Update — يعالج الرسائل (ضغطات المفاتيح، النتائج غير المتزامنة) ويعيد نموذجاً جديداً
  • View — يعرض النموذج كنص

بنية Model وحش، لكن هذه طبيعة TUI حقيقي — فهي تحتفظ بـ كل شيء يحتاجه العرض:

// internal/interface/tui/app.go
type Model struct {
	podcastService  *application.PodcastService
	downloadService *application.DownloadService
	playerService   *application.PlayerService

	state  viewState
	keys   keyMap
	theme  styles.Theme
	list   list.Model       // لوحة المكتبة
	detail viewport.Model   // لوحة تفاصيل الحلقة
	input  textinput.Model  // نافذة "إضافة موجة"
	// ...مؤثرات، حالة، أبعاد، إعدادات، حالة التشغيل
}

إليك الجزء المهم: واجهة TUI تعتمد على خدمات التطبيق، ولا يحصل العكس أبداً. الواجهة مستهلك. لو أردتُ إضافة واجهة ويب أو واجهة برمجية HTTP غداً، لجلست بجانب الـ TUI، تستدعي نفس PodcastService.


🎛️ نجمة العرض: المُذيع المركّب

هذا الجزء المفضّل لدي في المشروع كله. حين تملك مشغّلاً حقيقياً، تريده أن يتكامل مع سطح المكتب — يجب أن تعمل مفاتيح الوسائط (MPRIS على لينكس)، ومن الممتع إظهار «يُشغَّل الآن» في ديسكورد.

كلٌّ من MPRIS و Discord يستهلكان نفس الإشارة (حالة التشغيل + الموضع). لذا عرّفتُ منفذاً لها:

// في domain
type PlaybackBroadcaster interface {
	PublishState(state PlaybackState, metadata PlaybackMetadata) error
	PublishPosition(positionSec float64, durationSec float64) error
	SetController(controller PlaybackController)
	Close() error
}

ثم مركّب يوزّع الأحداث على أي عدد من المُذيعين:

// internal/infrastructure/system/composite_broadcaster.go
func NewCompositeBroadcaster(broadcasters ...domain.PlaybackBroadcaster) domain.PlaybackBroadcaster {
	filtered := make([]domain.PlaybackBroadcaster, 0, len(broadcasters))
	for _, b := range broadcasters {
		if b != nil { // تخطّي المُذيعين الفارغين بأمان (مثلاً MPRIS غير متاح)
			filtered = append(filtered, b)
		}
	}
	return &compositeBroadcaster{broadcasters: filtered}
}

func (b *compositeBroadcaster) PublishState(
	state domain.PlaybackState,
	metadata domain.PlaybackMetadata,
) error {
	var errs []error
	for _, broadcaster := range b.broadcasters {
		if err := broadcaster.PublishState(state, metadata); err != nil {
			errs = append(errs, err)
		}
	}
	return errors.Join(errs...) // فشل واحد لا يقتل البقية
}

يعتمد PlayerService على PlaybackBroadcaster واحد فقط. لا يعرف — ولا يهمّه — إن كان ذلك MPRIS وحده، أم MPRIS + Discord، أم شيئاً ثالثاً أضيفه لاحقاً. كانت إضافة دعم Discord حرفياً إلحاقاً بقائمة (slice).

وذلك errors.Join في النهاية؟ يعني أن عثرة في Discord لا تكسر مفاتيح الوسائط لديك. مَرِن بنيوياً.


🔌 توصيل كل شيء معاً: main.go

كل الطبقات مستقلة. تُجمَّع معاً في مكان واحد فقط — cmd/gocaster/main.go. هذا حقن الاعتماديات على طريقة Go: صريح، في main، بلا إطار:

// cmd/gocaster/main.go
func main() {
	cfg, err := config.LoadOrCreate()
	if err != nil {
		log.Fatal("fatal: ", err)
	}

	// البنية التحتية
	repo, err := persistence.NewSQLiteRepo(cfg.DatabasePath)
	if err != nil {
		log.Fatal("fatal: ", err)
	}
	fetcher := rss.NewFeedFetcher()

	// خدمات التطبيق
	podcastSvc := application.NewPodcastService(repo, fetcher)
	downloadSvc := application.NewDownloadService(repo, cfg.DownloadPath)

	// المشغّل + المُذيعون (نمط مركّب)
	mpvPlayer := player.NewMPVPlayer()
	mprisBroadcaster, err := system.NewMPRISBroadcaster()
	if err != nil {
		log.Printf("Warning: failed to create MPRIS broadcaster: %v", err)
	}

	broadcasters := []domain.PlaybackBroadcaster{mprisBroadcaster}
	if cfg.DiscordPresence {
		if discordBroadcaster, err := system.NewDiscordBroadcaster(cfg.DiscordClientID); err == nil {
			broadcasters = append(broadcasters, discordBroadcaster)
		}
	}
	broadcaster := system.NewCompositeBroadcaster(broadcasters...)
	playerSvc := application.NewPlayerService(repo, mpvPlayer, broadcaster)

	// TUI
	model := tui.NewModel(podcastSvc, downloadSvc, playerSvc, settings, saveSettings, customThemesDir)
	p := tea.NewProgram(model)
	p.Run()
}

اقرأها من الأعلى إلى الأسفل: الإعدادات ← الاستمرارية ← RSS ← الخدمات ← المشغّل ← المُذيعون ← الـ TUI. كل اعتمادية تُلبّى بالترتيب. إن أردت فهم شكل التطبيق كاملاً، فهذا الملف هو الخريطة.


🔄 استبدال المشغّل: مكسب في الواقع

أتذكر حين قلت إن الهندسة برّرت وجودها؟ إليك المثال الملموس من سجلّ git.

تنفيذ المشغّل الأول لـ Gocaster كان يستدعي أمر mpv — يُنشئ عملية فرعية لكل جلسة تشغيل. كان يعمل، لكنه كان أخرق: لا استعلام نظيف عن الحالة، ولا تحكّم سلس، وإدارة دورة حياة محرجة.

لذا استبدلتهُ بـ نسخة libmpv مضمَّنة عبر go-mpv. استدعاءات API مباشرة، Command([]string{"loadfile", source, "replace"})، وGetProperty("time-pos", ...)، وكل ما يلزم.

شاشة المشغّل المخصصة في Gocaster — تشغيل/إيقاف، تخطٍّ 15 ثانية، حقل قفز، شريط تقدّم حيّ، وملاحظات الحلقة.

لمس التغيير ملفاً واحداًmpv_player.go — بالإضافة إلى التوصيل في main.go. بقيت واجهة Player في النطاق كما هي. وبقي PlayerService كما هو. وبقيت واجهة TUI كما هي. ولأن لا شيء اعتمد على التنفيذ الملموس، أصبحت خلفية التشغيل بأكملها قابلة للاستبدال خلف حدّ نوع.

هذه ليست «مرونة نظرية يوماً ما». هذه مرونة استخدمتُها، بعد ثلاثة أسابيع.


🧠 الدروس المستفادة

✅ ما نجح

  • الهندسة النظيفة ليست عبئاً هنا — بل سبب بقاء التطبيق معقولاً. ستة أسابيع من الأمسيات، MPRIS و Discord، ونظام سمات، وطابور تنزيل، واستبدال المشغّل الداخلي، وحفظ الإعدادات — والنواة لم تتحوّل أبداً إلى سباغيتي.
  • الواجهات عند حدّ النطاق تجعل الاختبار مجانياً. لا شبكة، ولا قاعدة بيانات، ولا إطار محاكاة. مجرّد بدائل (stubs).
  • النمط المركّب للمُذيعين يستحق الاحتفاظ به. قائمة واحدة، errors.Join، وانتهى. إضافة قنوات إخراج أمر تافه.
  • Bubble Tea + معمارية Elm ملاءمة حقيقية لـ TUI ذات حالة. دوال التحديث النقية سهلة الفهم والاختبار.

⚠️ ما أراقبه

  • بنية Model في الـ TUI تكبر. إنها طبيعة TUI غنيّ، لكنها رائحة تستحق المراقبة. فكّرتُ في فصل حالة التشغيل في نموذج فرعي خاص بها.
  • CGO من أجل go-sqlite3 وgo-mpv. أصبح Gocaster الآن يحتاج إلى سلسلة أدوات C للبناء (CGO_ENABLED=1). تلك مفاضلة مقابل SQLite أصيلة وتشغيلاً مضمَّناً — تستحق لتطبيق سطح مكتب، لكن انتبه عند الاحتواء (أغطي تلك المعضلة تحديداً في مقال الـ Docker الخاص بي).
  • الحالات الحدّية في gofeed لـ RSS لا تنتهي. صيغ مدّة غريبة، ومرفقات مفقودة، وفضاءات أسماء. الاحتواء عند حدّ المُكيّف أنقذ النطاق من تلك الفوضى — لكن المُكيّف نفسه هو المكان الذي يحتاج أكثر كود دفاعي.

🚀 إلى أين يتّجه

Gocaster حيّ يُرزق على جهازي كل يوم. على خارطة الطريق:

  • مزامنة موضع التشغيل عبر الحلقات بدقّة أكبر
  • مزيد من اكتشاف السمات والإعدادات لكل بودكاست
  • جدولة تنزيلات أذكى

الهندسة تعني أن لا واحدة من هذه تتطلب فكّ تشابك النواة — فهي تندسّ كحالات استخدام أو مُكيّفات جديدة. هذا هو العائد الحقيقي لإتقان التطبّق مبكراً.


🤓 أفكار أخيرة

إن كان هناك خلاصة واحدة، فهي هذه: الهندسة النظيفة في Go ليست عن الطقوس — بل عن الفواصل. ضع واجهة منفذ عند كل حدّ خارجي (قاعدة بيانات، شبكة، نظام ملفات، واجهة، تكاملات النظام)، ودع الاتجاهات تشير إلى الداخل، ووصّل كل شيء صراحةً في main. تنال قابلية الاختبار، والقابلية للاستبدال، وقاعدة كود تبقى مقروءة وهي تنمو.

بدأ Gocaster بـ «أريد عميل بودكاست أفضل». فتحوّل إلى أحد أنظف قواعد الكود التي كتبتُها، وبصراحة، واحدة من أمتعها. إن كنت تبني TUI — أو أي تطبيق Go ذي حالة — فامنح النهج الطبقي نظرة جادّة. التفكير المسبق يردّ لك الثمن كل أسبوع.

الكود على GitHub. افلته (fork it)، وافتح طلب سحب، وأخبرني بما فيه من خطأ.

إلى اللقاء في المرة القادمة: برمجة سعيدة. 💻✨